mirror of
https://github.com/openai/codex.git
synced 2026-02-03 15:33:41 +00:00
Compare commits
24 Commits
patch-squa
...
easong/rus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
339c114cb0 | ||
|
|
7e4cb46cd0 | ||
|
|
b791c1cc3e | ||
|
|
d357fb7dba | ||
|
|
423f92a71b | ||
|
|
701c2f0802 | ||
|
|
7cad669e09 | ||
|
|
e5b2703374 | ||
|
|
96225c0d8f | ||
|
|
04ac0839e7 | ||
|
|
f7025fc317 | ||
|
|
90db5317d7 | ||
|
|
47dcb4377c | ||
|
|
df17f64784 | ||
|
|
b1a93a06f0 | ||
|
|
0a47c9f5d9 | ||
|
|
865bd3a773 | ||
|
|
5477eb9c5e | ||
|
|
7c7ccdd72b | ||
|
|
b668483951 | ||
|
|
45514562d9 | ||
|
|
0ce885cb98 | ||
|
|
3ba4ac8ee6 | ||
|
|
339a8b69f4 |
270
codex-rs/Cargo.lock
generated
270
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.43"
|
||||
@@ -797,13 +815,24 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"hex",
|
||||
"html-escape",
|
||||
"pretty_assertions",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"ureq",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"webbrowser",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -951,6 +980,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 +1044,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"
|
||||
@@ -1938,6 +1987,15 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html-escape"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
|
||||
dependencies = [
|
||||
"utf8-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.3.1"
|
||||
@@ -2455,6 +2513,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 +2871,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 +3030,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"
|
||||
@@ -3670,6 +3781,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
@@ -3801,7 +3913,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",
|
||||
@@ -3992,7 +4106,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 +4265,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 +4640,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 +4835,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"
|
||||
@@ -5150,6 +5287,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"
|
||||
@@ -5162,6 +5316,18 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-width"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -5385,6 +5551,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"
|
||||
@@ -5573,6 +5773,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 +5809,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 +5856,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 +5874,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 +5892,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 +5922,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 +5940,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 +5958,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 +5976,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"
|
||||
|
||||
@@ -5,6 +5,7 @@ use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_login::AuthMode;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE;
|
||||
use codex_login::OPENAI_API_KEY_ENV_VAR;
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_login::login_with_chatgpt;
|
||||
@@ -13,15 +14,19 @@ use codex_login::logout;
|
||||
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);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error logging in: {e}");
|
||||
std::process::exit(1);
|
||||
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||
eprintln!("Error logging in: address already in use");
|
||||
std::process::exit(EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE);
|
||||
} else {
|
||||
eprintln!("Error logging in: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,22 @@ edition = "2024"
|
||||
name = "codex-login"
|
||||
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"] }
|
||||
hex = "0.4"
|
||||
html-escape = "0.2"
|
||||
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,7 +26,16 @@ tokio = { version = "1", features = [
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
tracing = "0.1"
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
webbrowser = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4.1"
|
||||
tempfile = "3"
|
||||
ureq = { version = "2", default-features = false, features = ["tls", "json"] }
|
||||
wiremock = "0.6"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
209
codex-rs/login/src/auth.rs
Normal file
209
codex-rs/login/src/auth.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::auth_store::AuthDotJson;
|
||||
use crate::auth_store::get_auth_file;
|
||||
use crate::auth_store::try_read_auth_json;
|
||||
use crate::auth_store::update_tokens;
|
||||
use crate::refresh::try_refresh_token;
|
||||
use crate::token_data::TokenData;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Copy)]
|
||||
pub enum AuthMode {
|
||||
ApiKey,
|
||||
ChatGPT,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodexAuth {
|
||||
pub mode: AuthMode,
|
||||
|
||||
pub(crate) api_key: Option<String>,
|
||||
pub(crate) auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
|
||||
pub(crate) auth_file: PathBuf,
|
||||
}
|
||||
|
||||
impl PartialEq for CodexAuth {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.mode == other.mode
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexAuth {
|
||||
pub fn from_api_key(api_key: &str) -> Self {
|
||||
Self {
|
||||
api_key: Some(api_key.to_owned()),
|
||||
mode: AuthMode::ApiKey,
|
||||
auth_file: PathBuf::new(),
|
||||
auth_dot_json: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads from auth.json or OPENAI_API_KEY
|
||||
pub fn from_codex_home(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
|
||||
load_auth(codex_home, true)
|
||||
}
|
||||
|
||||
pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
|
||||
let auth_dot_json: Option<AuthDotJson> = self.get_current_auth_json();
|
||||
match auth_dot_json {
|
||||
Some(AuthDotJson {
|
||||
tokens: Some(mut tokens),
|
||||
last_refresh: Some(last_refresh),
|
||||
..
|
||||
}) => {
|
||||
if last_refresh < Utc::now() - chrono::Duration::days(28) {
|
||||
let refresh_response = tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
try_refresh_token(tokens.refresh_token.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::other("timed out while refreshing OpenAI API key")
|
||||
})?
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
let updated_auth_dot_json = update_tokens(
|
||||
&self.auth_file,
|
||||
refresh_response.id_token,
|
||||
refresh_response.access_token,
|
||||
refresh_response.refresh_token,
|
||||
)?;
|
||||
|
||||
tokens = updated_auth_dot_json
|
||||
.tokens
|
||||
.clone()
|
||||
.ok_or(std::io::Error::other(
|
||||
"Token data is not available after refresh.",
|
||||
))?;
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
let mut auth_lock = self.auth_dot_json.lock().unwrap();
|
||||
*auth_lock = Some(updated_auth_dot_json);
|
||||
}
|
||||
|
||||
Ok(tokens)
|
||||
}
|
||||
_ => Err(std::io::Error::other("Token data is not available.")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_token(&self) -> Result<String, std::io::Error> {
|
||||
match self.mode {
|
||||
AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()),
|
||||
AuthMode::ChatGPT => {
|
||||
let id_token = self.get_token_data().await?.access_token;
|
||||
|
||||
Ok(id_token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_account_id(&self) -> Option<String> {
|
||||
self.get_current_token_data()
|
||||
.and_then(|t| t.account_id.clone())
|
||||
}
|
||||
|
||||
pub fn get_plan_type(&self) -> Option<String> {
|
||||
self.get_current_token_data()
|
||||
.and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string()))
|
||||
}
|
||||
|
||||
fn get_current_auth_json(&self) -> Option<AuthDotJson> {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
self.auth_dot_json.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn get_current_token_data(&self) -> Option<TokenData> {
|
||||
self.get_current_auth_json().and_then(|t| t.tokens.clone())
|
||||
}
|
||||
|
||||
/// Consider this private to integration tests.
|
||||
pub fn create_dummy_chatgpt_auth_for_testing() -> Self {
|
||||
let auth_dot_json = AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: Default::default(),
|
||||
id_token_raw: String::new(),
|
||||
access_token: "Access Token".to_string(),
|
||||
refresh_token: "test".to_string(),
|
||||
account_id: Some("account_id".to_string()),
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
};
|
||||
|
||||
let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json)));
|
||||
Self {
|
||||
api_key: None,
|
||||
mode: AuthMode::ChatGPT,
|
||||
auth_file: PathBuf::new(),
|
||||
auth_dot_json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_auth(
|
||||
codex_home: &Path,
|
||||
include_env_var: bool,
|
||||
) -> std::io::Result<Option<CodexAuth>> {
|
||||
// First, check to see if there is a valid auth.json file. If not, we fall
|
||||
// back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable
|
||||
let auth_file = get_auth_file(codex_home);
|
||||
let auth_dot_json = match try_read_auth_json(&auth_file) {
|
||||
Ok(auth) => auth,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => {
|
||||
return match read_openai_api_key_from_env() {
|
||||
Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))),
|
||||
None => Ok(None),
|
||||
};
|
||||
}
|
||||
// Though if auth.json exists but is malformed, do not fall back to the
|
||||
// env var because the user may be expecting to use AuthMode::ChatGPT.
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let AuthDotJson {
|
||||
openai_api_key: auth_json_api_key,
|
||||
tokens,
|
||||
last_refresh,
|
||||
} = auth_dot_json;
|
||||
|
||||
// If the auth.json has an API key AND does not appear to be on a plan that
|
||||
// should prefer AuthMode::ChatGPT, use AuthMode::ApiKey.
|
||||
if let Some(api_key) = &auth_json_api_key {
|
||||
match &tokens {
|
||||
Some(tokens) => {
|
||||
if tokens.is_plan_that_should_use_api_key() {
|
||||
return Ok(Some(CodexAuth::from_api_key(api_key)));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Let's assume they are trying to use their API key.
|
||||
return Ok(Some(CodexAuth::from_api_key(api_key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(CodexAuth {
|
||||
api_key: None,
|
||||
mode: AuthMode::ChatGPT,
|
||||
auth_file,
|
||||
auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens,
|
||||
last_refresh,
|
||||
}))),
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_openai_api_key_from_env() -> Option<String> {
|
||||
std::env::var(crate::OPENAI_API_KEY_ENV_VAR)
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
149
codex-rs/login/src/auth_store.rs
Normal file
149
codex-rs/login/src/auth_store.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::token_data::TokenData;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
|
||||
pub struct AuthDotJson {
|
||||
#[serde(rename = "OPENAI_API_KEY")]
|
||||
pub openai_api_key: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tokens: Option<TokenData>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_refresh: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub fn get_auth_file(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join("auth.json")
|
||||
}
|
||||
|
||||
/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)`
|
||||
/// if a file was removed, `Ok(false)` if no auth file was present.
|
||||
pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
|
||||
let auth_file = get_auth_file(codex_home);
|
||||
match std::fs::remove_file(&auth_file) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to read and deserialize the `auth.json` file at the given path.
|
||||
/// Returns the full AuthDotJson structure.
|
||||
pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result<AuthDotJson> {
|
||||
let mut file = File::open(auth_file)?;
|
||||
let mut contents = String::new();
|
||||
use std::io::Read as _;
|
||||
file.read_to_string(&mut contents)?;
|
||||
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
|
||||
Ok(auth_dot_json)
|
||||
}
|
||||
|
||||
pub(crate) fn write_auth_json(
|
||||
auth_file: &Path,
|
||||
auth_dot_json: &AuthDotJson,
|
||||
) -> std::io::Result<()> {
|
||||
let json_data = serde_json::to_string_pretty(auth_dot_json)?;
|
||||
let mut options = OpenOptions::new();
|
||||
options.truncate(true).write(true).create(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(auth_file)?;
|
||||
use std::io::Write as _;
|
||||
file.write_all(json_data.as_bytes())?;
|
||||
file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
};
|
||||
write_auth_json(&get_auth_file(codex_home), &auth_dot_json)
|
||||
}
|
||||
|
||||
pub(crate) fn update_tokens(
|
||||
auth_file: &Path,
|
||||
id_token: String,
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
) -> std::io::Result<AuthDotJson> {
|
||||
let mut prior_access: Option<String> = None;
|
||||
let mut prior_refresh: Option<String> = None;
|
||||
let mut auth = match try_read_auth_json(auth_file) {
|
||||
Ok(a) => a,
|
||||
Err(_) => {
|
||||
// Try to salvage existing access/refresh from raw JSON on disk
|
||||
if let Ok(mut f) = File::open(auth_file) {
|
||||
let mut contents = String::new();
|
||||
use std::io::Read as _;
|
||||
if f.read_to_string(&mut contents).is_ok() {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&contents) {
|
||||
prior_access = val
|
||||
.get("tokens")
|
||||
.and_then(|t| t.get("access_token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
prior_refresh = val
|
||||
.get("tokens")
|
||||
.and_then(|t| t.get("refresh_token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let now = Utc::now();
|
||||
auth.last_refresh = Some(now);
|
||||
|
||||
let new_tokens = match auth.tokens.take() {
|
||||
Some(mut tokens) => {
|
||||
tokens.id_token_raw = id_token;
|
||||
if let Some(a) = access_token.clone() {
|
||||
tokens.access_token = a;
|
||||
}
|
||||
if let Some(r) = refresh_token.clone() {
|
||||
tokens.refresh_token = r;
|
||||
}
|
||||
// Re-parse id_token_raw into parsed fields
|
||||
tokens.id_token = crate::token_data::parse_id_token(&tokens.id_token_raw)
|
||||
.map_err(std::io::Error::other)?;
|
||||
tokens
|
||||
}
|
||||
None => {
|
||||
// Construct fresh TokenData from provided values
|
||||
let a = access_token
|
||||
.or_else(|| prior_access.clone())
|
||||
.ok_or_else(|| std::io::Error::other("missing access_token"))?;
|
||||
let r = refresh_token
|
||||
.or_else(|| prior_refresh.clone())
|
||||
.ok_or_else(|| std::io::Error::other("missing refresh_token"))?;
|
||||
crate::token_data::TokenData::from_raw(id_token, a, r, None)
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
};
|
||||
|
||||
auth.tokens = Some(new_tokens);
|
||||
write_auth_json(auth_file, &auth)?;
|
||||
try_read_auth_json(auth_file)
|
||||
}
|
||||
124
codex-rs/login/src/entrypoints.rs
Normal file
124
codex-rs/login/src/entrypoints.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use std::path::Path;
|
||||
use std::process::Child;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::server;
|
||||
|
||||
/// Represents a running login subprocess. The child can be killed by holding
|
||||
/// 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>>>,
|
||||
}
|
||||
|
||||
impl SpawnedLogin {
|
||||
/// Attempts to extract the login URL printed by the spawned login server.
|
||||
///
|
||||
/// The login server prints the authorization URL to stderr on its own line
|
||||
/// in case the browser does not open automatically. We scan the captured
|
||||
/// stderr buffer from the end and return the last line that looks like an
|
||||
/// http(s) URL.
|
||||
pub fn get_login_url(&self) -> Option<String> {
|
||||
let buf = self.stderr.lock().ok()?;
|
||||
let text = String::from_utf8_lossy(&buf);
|
||||
for line in text.lines().rev() {
|
||||
let s = line.trim();
|
||||
if s.starts_with("http://") || s.starts_with("https://") {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 current_exe = std::env::current_exe()?;
|
||||
let mut cmd = std::process::Command::new(current_exe);
|
||||
cmd.arg("login")
|
||||
.env("CODEX_HOME", codex_home)
|
||||
.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(mut out) = child.stdout.take() {
|
||||
let buf = stdout_buf.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut tmp = Vec::new();
|
||||
let _ = std::io::copy(&mut out, &mut tmp);
|
||||
if let Ok(mut b) = buf.lock() {
|
||||
b.extend_from_slice(&tmp);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(mut err) = child.stderr.take() {
|
||||
let buf = stderr_buf.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut tmp = Vec::new();
|
||||
let _ = std::io::copy(&mut err, &mut tmp);
|
||||
if let Ok(mut b) = buf.lock() {
|
||||
b.extend_from_slice(&tmp);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SpawnedLogin {
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
stdout: stdout_buf,
|
||||
stderr: stderr_buf,
|
||||
})
|
||||
}
|
||||
|
||||
/// Entrypoint used by the CLI to run the local login server.
|
||||
pub async fn login_with_chatgpt(codex_home: &Path) -> std::io::Result<()> {
|
||||
let client_id = std::env::var("CODEX_CLIENT_ID")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| crate::CLIENT_ID.to_string());
|
||||
|
||||
let codex_home = codex_home.to_path_buf();
|
||||
let client_id_cloned = client_id.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let opts = server::LoginServerOptions::for_cli(&codex_home, &client_id_cloned);
|
||||
server::run_local_login_server_with_options(opts)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("task join error: {e}")))??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn spawn_login_in_process(
|
||||
codex_home: &Path,
|
||||
) -> (
|
||||
std::thread::JoinHandle<std::io::Result<()>>,
|
||||
std::sync::mpsc::Receiver<crate::server::LoginServerStatus>,
|
||||
std::sync::mpsc::Sender<()>,
|
||||
) {
|
||||
let (status_tx, status_rx) = std::sync::mpsc::channel();
|
||||
let (cancel_tx, cancel_rx) = std::sync::mpsc::channel();
|
||||
|
||||
let client_id = std::env::var("CODEX_CLIENT_ID")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| crate::CLIENT_ID.to_string());
|
||||
|
||||
let opts = server::LoginServerOptions::for_ui(
|
||||
codex_home,
|
||||
&client_id,
|
||||
status_tx,
|
||||
cancel_rx,
|
||||
server::DEFAULT_PORT,
|
||||
);
|
||||
|
||||
let handle = std::thread::spawn(move || server::run_local_login_server_with_options(opts));
|
||||
(handle, status_rx, cancel_tx)
|
||||
}
|
||||
100
codex-rs/login/src/error_page.html
Normal file
100
codex-rs/login/src/error_page.html
Normal file
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Sign-in error · 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: 520px;
|
||||
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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.title {
|
||||
text-align: center;
|
||||
color: var(--text-primary, #0D0D0D);
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
line-height: 36px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.message {
|
||||
align-self: stretch;
|
||||
color: var(--text-secondary, #5D5D5D);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.help {
|
||||
align-self: stretch;
|
||||
color: var(--text-secondary, #5D5D5D);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
</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">We couldn't complete sign-in</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="message" id="message">%%MESSAGE%%</div>
|
||||
</div>
|
||||
<div class="help">Try restarting the sign-in from the Codex CLI. You can also copy the URL from the terminal and open it manually.</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -1,756 +1,35 @@
|
||||
use chrono::DateTime;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
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::token_data::TokenData;
|
||||
use crate::token_data::parse_id_token;
|
||||
|
||||
mod auth;
|
||||
mod auth_store;
|
||||
mod entrypoints;
|
||||
mod pkce;
|
||||
mod refresh;
|
||||
mod server;
|
||||
mod success_url;
|
||||
mod token_data;
|
||||
|
||||
const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
|
||||
pub use auth::AuthMode;
|
||||
pub use auth::CodexAuth;
|
||||
pub use auth_store::AuthDotJson;
|
||||
pub use auth_store::get_auth_file;
|
||||
pub use auth_store::login_with_api_key;
|
||||
pub use auth_store::logout;
|
||||
pub use auth_store::try_read_auth_json;
|
||||
pub use entrypoints::SpawnedLogin;
|
||||
pub use entrypoints::login_with_chatgpt;
|
||||
pub use entrypoints::spawn_login_in_process;
|
||||
pub use entrypoints::spawn_login_with_chatgpt;
|
||||
pub use server::HeadlessOutcome;
|
||||
pub use server::Http;
|
||||
pub use server::LoginServerOptions;
|
||||
pub use server::LoginServerStatus;
|
||||
pub use server::process_callback_headless;
|
||||
pub use server::run_local_login_server_with_options;
|
||||
|
||||
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub(crate) const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Copy)]
|
||||
pub enum AuthMode {
|
||||
ApiKey,
|
||||
ChatGPT,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodexAuth {
|
||||
pub mode: AuthMode,
|
||||
|
||||
api_key: Option<String>,
|
||||
auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
|
||||
auth_file: PathBuf,
|
||||
}
|
||||
|
||||
impl PartialEq for CodexAuth {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.mode == other.mode
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexAuth {
|
||||
pub fn from_api_key(api_key: &str) -> Self {
|
||||
Self {
|
||||
api_key: Some(api_key.to_owned()),
|
||||
mode: AuthMode::ApiKey,
|
||||
auth_file: PathBuf::new(),
|
||||
auth_dot_json: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the available auth information from the auth.json or
|
||||
/// OPENAI_API_KEY environment variable.
|
||||
pub fn from_codex_home(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
|
||||
load_auth(codex_home, true)
|
||||
}
|
||||
|
||||
pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
|
||||
let auth_dot_json: Option<AuthDotJson> = self.get_current_auth_json();
|
||||
match auth_dot_json {
|
||||
Some(AuthDotJson {
|
||||
tokens: Some(mut tokens),
|
||||
last_refresh: Some(last_refresh),
|
||||
..
|
||||
}) => {
|
||||
if last_refresh < Utc::now() - chrono::Duration::days(28) {
|
||||
let refresh_response = tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
try_refresh_token(tokens.refresh_token.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::other("timed out while refreshing OpenAI API key")
|
||||
})?
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
let updated_auth_dot_json = update_tokens(
|
||||
&self.auth_file,
|
||||
refresh_response.id_token,
|
||||
refresh_response.access_token,
|
||||
refresh_response.refresh_token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tokens = updated_auth_dot_json
|
||||
.tokens
|
||||
.clone()
|
||||
.ok_or(std::io::Error::other(
|
||||
"Token data is not available after refresh.",
|
||||
))?;
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
let mut auth_lock = self.auth_dot_json.lock().unwrap();
|
||||
*auth_lock = Some(updated_auth_dot_json);
|
||||
}
|
||||
|
||||
Ok(tokens)
|
||||
}
|
||||
_ => Err(std::io::Error::other("Token data is not available.")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_token(&self) -> Result<String, std::io::Error> {
|
||||
match self.mode {
|
||||
AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()),
|
||||
AuthMode::ChatGPT => {
|
||||
let id_token = self.get_token_data().await?.access_token;
|
||||
|
||||
Ok(id_token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_account_id(&self) -> Option<String> {
|
||||
self.get_current_token_data()
|
||||
.and_then(|t| t.account_id.clone())
|
||||
}
|
||||
|
||||
pub fn get_plan_type(&self) -> Option<String> {
|
||||
self.get_current_token_data()
|
||||
.and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string()))
|
||||
}
|
||||
|
||||
fn get_current_auth_json(&self) -> Option<AuthDotJson> {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
self.auth_dot_json.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn get_current_token_data(&self) -> Option<TokenData> {
|
||||
self.get_current_auth_json().and_then(|t| t.tokens.clone())
|
||||
}
|
||||
|
||||
/// Consider this private to integration tests.
|
||||
pub fn create_dummy_chatgpt_auth_for_testing() -> Self {
|
||||
let auth_dot_json = AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: Default::default(),
|
||||
access_token: "Access Token".to_string(),
|
||||
refresh_token: "test".to_string(),
|
||||
account_id: Some("account_id".to_string()),
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
};
|
||||
|
||||
let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json)));
|
||||
Self {
|
||||
api_key: None,
|
||||
mode: AuthMode::ChatGPT,
|
||||
auth_file: PathBuf::new(),
|
||||
auth_dot_json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_auth(codex_home: &Path, include_env_var: bool) -> std::io::Result<Option<CodexAuth>> {
|
||||
// First, check to see if there is a valid auth.json file. If not, we fall
|
||||
// back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable
|
||||
// (if it is set).
|
||||
let auth_file = get_auth_file(codex_home);
|
||||
let auth_dot_json = match try_read_auth_json(&auth_file) {
|
||||
Ok(auth) => auth,
|
||||
// If auth.json does not exist, try to read the OPENAI_API_KEY from the
|
||||
// environment variable.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => {
|
||||
return match read_openai_api_key_from_env() {
|
||||
Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))),
|
||||
None => Ok(None),
|
||||
};
|
||||
}
|
||||
// Though if auth.json exists but is malformed, do not fall back to the
|
||||
// env var because the user may be expecting to use AuthMode::ChatGPT.
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let AuthDotJson {
|
||||
openai_api_key: auth_json_api_key,
|
||||
tokens,
|
||||
last_refresh,
|
||||
} = auth_dot_json;
|
||||
|
||||
// If the auth.json has an API key AND does not appear to be on a plan that
|
||||
// should prefer AuthMode::ChatGPT, use AuthMode::ApiKey.
|
||||
if let Some(api_key) = &auth_json_api_key {
|
||||
// Should any of these be AuthMode::ChatGPT with the api_key set?
|
||||
// Does AuthMode::ChatGPT indicate that there is an auth.json that is
|
||||
// "refreshable" even if we are using the API key for auth?
|
||||
match &tokens {
|
||||
Some(tokens) => {
|
||||
if tokens.is_plan_that_should_use_api_key() {
|
||||
return Ok(Some(CodexAuth::from_api_key(api_key)));
|
||||
} else {
|
||||
// Ignore the API key and fall through to ChatGPT auth.
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// We have an API key but no tokens in the auth.json file.
|
||||
// Perhaps the user ran `codex login --api-key <KEY>` or updated
|
||||
// auth.json by hand. Either way, let's assume they are trying
|
||||
// to use their API key.
|
||||
return Ok(Some(CodexAuth::from_api_key(api_key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For the AuthMode::ChatGPT variant, perhaps neither api_key nor
|
||||
// openai_api_key should exist?
|
||||
Ok(Some(CodexAuth {
|
||||
api_key: None,
|
||||
mode: AuthMode::ChatGPT,
|
||||
auth_file,
|
||||
auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens,
|
||||
last_refresh,
|
||||
}))),
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_openai_api_key_from_env() -> Option<String> {
|
||||
env::var(OPENAI_API_KEY_ENV_VAR)
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn get_auth_file(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join("auth.json")
|
||||
}
|
||||
|
||||
/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)`
|
||||
/// if a file was removed, `Ok(false)` if no auth file was present.
|
||||
pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
|
||||
let auth_file = get_auth_file(codex_home);
|
||||
match remove_file(&auth_file) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a running login subprocess. The child can be killed by holding
|
||||
/// 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>>>,
|
||||
}
|
||||
|
||||
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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_pipe_reader<R: Read + Send + 'static>(mut reader: R, buf: Arc<Mutex<Vec<u8>>>) {
|
||||
std::thread::spawn(move || {
|
||||
let _ = io::copy(&mut reader, &mut AppendWriter { buf });
|
||||
});
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
Ok(SpawnedLogin {
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
stdout: stdout_buf,
|
||||
stderr: stderr_buf,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
};
|
||||
write_auth_json(&get_auth_file(codex_home), &auth_dot_json)
|
||||
}
|
||||
|
||||
/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory.
|
||||
/// Returns the full AuthDotJson structure after refreshing if necessary.
|
||||
pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result<AuthDotJson> {
|
||||
let mut file = File::open(auth_file)?;
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
|
||||
|
||||
Ok(auth_dot_json)
|
||||
}
|
||||
|
||||
fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> {
|
||||
let json_data = serde_json::to_string_pretty(auth_dot_json)?;
|
||||
let mut options = OpenOptions::new();
|
||||
options.truncate(true).write(true).create(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(auth_file)?;
|
||||
file.write_all(json_data.as_bytes())?;
|
||||
file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_tokens(
|
||||
auth_file: &Path,
|
||||
id_token: String,
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
) -> std::io::Result<AuthDotJson> {
|
||||
let mut auth_dot_json = try_read_auth_json(auth_file)?;
|
||||
|
||||
let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default);
|
||||
tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?;
|
||||
if let Some(access_token) = access_token {
|
||||
tokens.access_token = access_token.to_string();
|
||||
}
|
||||
if let Some(refresh_token) = refresh_token {
|
||||
tokens.refresh_token = refresh_token.to_string();
|
||||
}
|
||||
auth_dot_json.last_refresh = Some(Utc::now());
|
||||
write_auth_json(auth_file, &auth_dot_json)?;
|
||||
Ok(auth_dot_json)
|
||||
}
|
||||
|
||||
async fn try_refresh_token(refresh_token: String) -> std::io::Result<RefreshResponse> {
|
||||
let refresh_request = RefreshRequest {
|
||||
client_id: CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
scope: "openid profile email",
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://auth.openai.com/oauth/token")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&refresh_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let refresh_response = response
|
||||
.json::<RefreshResponse>()
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
Ok(refresh_response)
|
||||
} else {
|
||||
Err(std::io::Error::other(format!(
|
||||
"Failed to refresh token: {}",
|
||||
response.status()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest {
|
||||
client_id: &'static str,
|
||||
grant_type: &'static str,
|
||||
refresh_token: String,
|
||||
scope: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct RefreshResponse {
|
||||
id_token: String,
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Expected structure for $CODEX_HOME/auth.json.
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
|
||||
pub struct AuthDotJson {
|
||||
#[serde(rename = "OPENAI_API_KEY")]
|
||||
pub openai_api_key: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tokens: Option<TokenData>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_refresh: Option<DateTime<Utc>>,
|
||||
}
|
||||
pub const EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE: i32 = 13;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![expect(clippy::expect_used, clippy::unwrap_used)]
|
||||
use super::*;
|
||||
use crate::token_data::IdTokenInfo;
|
||||
use crate::token_data::KnownPlan;
|
||||
use crate::token_data::PlanType;
|
||||
use base64::Engine;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z";
|
||||
|
||||
#[test]
|
||||
fn writes_api_key_and_loads_auth() {
|
||||
let dir = tempdir().unwrap();
|
||||
login_with_api_key(dir.path(), "sk-test-key").unwrap();
|
||||
let auth = load_auth(dir.path(), false).unwrap().unwrap();
|
||||
assert_eq!(auth.mode, AuthMode::ApiKey);
|
||||
assert_eq!(auth.api_key.as_deref(), Some("sk-test-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_from_env_var_if_env_var_exists() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
let env_var = std::env::var(OPENAI_API_KEY_ENV_VAR);
|
||||
|
||||
if let Ok(env_var) = env_var {
|
||||
let auth = load_auth(dir.path(), true).unwrap().unwrap();
|
||||
assert_eq!(auth.mode, AuthMode::ApiKey);
|
||||
assert_eq!(auth.api_key, Some(env_var));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
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,
|
||||
auth_dot_json,
|
||||
auth_file: _,
|
||||
} = load_auth(codex_home.path(), false).unwrap().unwrap();
|
||||
assert_eq!(None, api_key);
|
||||
assert_eq!(AuthMode::ChatGPT, mode);
|
||||
|
||||
let guard = auth_dot_json.lock().unwrap();
|
||||
let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
|
||||
assert_eq!(
|
||||
&AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: IdTokenInfo {
|
||||
email: Some("user@example.com".to_string()),
|
||||
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
|
||||
},
|
||||
access_token: "test-access-token".to_string(),
|
||||
refresh_token: "test-refresh-token".to_string(),
|
||||
account_id: None,
|
||||
}),
|
||||
last_refresh: Some(
|
||||
DateTime::parse_from_rfc3339(LAST_REFRESH)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
),
|
||||
},
|
||||
auth_dot_json
|
||||
)
|
||||
}
|
||||
|
||||
/// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in
|
||||
/// [`TokenData::is_plan_that_should_use_api_key`], it should use
|
||||
/// [`AuthMode::ChatGPT`].
|
||||
#[tokio::test]
|
||||
async fn pro_account_with_api_key_still_uses_chatgpt_auth() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: Some("sk-test-key".to_string()),
|
||||
chatgpt_plan_type: "pro".to_string(),
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let CodexAuth {
|
||||
api_key,
|
||||
mode,
|
||||
auth_dot_json,
|
||||
auth_file: _,
|
||||
} = load_auth(codex_home.path(), false).unwrap().unwrap();
|
||||
assert_eq!(None, api_key);
|
||||
assert_eq!(AuthMode::ChatGPT, mode);
|
||||
|
||||
let guard = auth_dot_json.lock().unwrap();
|
||||
let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
|
||||
assert_eq!(
|
||||
&AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: IdTokenInfo {
|
||||
email: Some("user@example.com".to_string()),
|
||||
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
|
||||
},
|
||||
access_token: "test-access-token".to_string(),
|
||||
refresh_token: "test-refresh-token".to_string(),
|
||||
account_id: None,
|
||||
}),
|
||||
last_refresh: Some(
|
||||
DateTime::parse_from_rfc3339(LAST_REFRESH)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
),
|
||||
},
|
||||
auth_dot_json
|
||||
)
|
||||
}
|
||||
|
||||
/// If the OPENAI_API_KEY is set in auth.json and it is an enterprise
|
||||
/// account, then it should use [`AuthMode::ApiKey`].
|
||||
#[tokio::test]
|
||||
async fn enterprise_account_with_api_key_uses_chatgpt_auth() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: Some("sk-test-key".to_string()),
|
||||
chatgpt_plan_type: "enterprise".to_string(),
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let CodexAuth {
|
||||
api_key,
|
||||
mode,
|
||||
auth_dot_json,
|
||||
auth_file: _,
|
||||
} = load_auth(codex_home.path(), false).unwrap().unwrap();
|
||||
assert_eq!(Some("sk-test-key".to_string()), api_key);
|
||||
assert_eq!(AuthMode::ApiKey, mode);
|
||||
|
||||
let guard = auth_dot_json.lock().expect("should unwrap");
|
||||
assert!(guard.is_none(), "auth_dot_json should be None");
|
||||
}
|
||||
|
||||
struct AuthFileParams {
|
||||
openai_api_key: Option<String>,
|
||||
chatgpt_plan_type: String,
|
||||
}
|
||||
|
||||
fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result<()> {
|
||||
let auth_file = get_auth_file(codex_home);
|
||||
// Create a minimal valid JWT for the id_token field.
|
||||
#[derive(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",
|
||||
"email_verified": true,
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53",
|
||||
"chatgpt_plan_type": params.chatgpt_plan_type,
|
||||
"chatgpt_user_id": "user-12345",
|
||||
"user_id": "user-12345",
|
||||
}
|
||||
});
|
||||
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
|
||||
let header_b64 = b64(&serde_json::to_vec(&header)?);
|
||||
let payload_b64 = b64(&serde_json::to_vec(&payload)?);
|
||||
let signature_b64 = b64(b"sig");
|
||||
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||
|
||||
let auth_json_data = json!({
|
||||
"OPENAI_API_KEY": params.openai_api_key,
|
||||
"tokens": {
|
||||
"id_token": fake_jwt,
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token"
|
||||
},
|
||||
"last_refresh": LAST_REFRESH,
|
||||
});
|
||||
let auth_json = serde_json::to_string_pretty(&auth_json_data)?;
|
||||
std::fs::write(auth_file, auth_json)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_token_info_handles_missing_fields() {
|
||||
// Payload without email or plan should yield None values.
|
||||
let header = serde_json::json!({"alg": "none", "typ": "JWT"});
|
||||
let payload = serde_json::json!({"sub": "123"});
|
||||
let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&header).unwrap());
|
||||
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&payload).unwrap());
|
||||
let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig");
|
||||
let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||
|
||||
let info = parse_id_token(&jwt).expect("should parse");
|
||||
assert!(info.email.is_none());
|
||||
assert!(info.chatgpt_plan_type.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_api_key_from_auth_json() {
|
||||
let dir = tempdir().unwrap();
|
||||
let auth_file = dir.path().join("auth.json");
|
||||
std::fs::write(
|
||||
auth_file,
|
||||
r#"
|
||||
{
|
||||
"OPENAI_API_KEY": "sk-test-key",
|
||||
"tokens": null,
|
||||
"last_refresh": null
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let auth = load_auth(dir.path(), false).unwrap().unwrap();
|
||||
assert_eq!(auth.mode, AuthMode::ApiKey);
|
||||
assert_eq!(auth.api_key, Some("sk-test-key".to_string()));
|
||||
|
||||
assert!(auth.get_token_data().await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logout_removes_auth_file() -> Result<(), std::io::Error> {
|
||||
let dir = tempdir()?;
|
||||
login_with_api_key(dir.path(), "sk-test-key")?;
|
||||
assert!(dir.path().join("auth.json").exists());
|
||||
let removed = logout(dir.path())?;
|
||||
assert!(removed);
|
||||
assert!(!dir.path().join("auth.json").exists());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
mod lib_tests;
|
||||
|
||||
337
codex-rs/login/src/lib_tests.rs
Normal file
337
codex-rs/login/src/lib_tests.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
#![expect(clippy::expect_used, clippy::unwrap_used)]
|
||||
use crate::auth::AuthMode;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::auth::load_auth;
|
||||
use crate::auth_store::get_auth_file;
|
||||
use crate::auth_store::logout;
|
||||
use crate::token_data::IdTokenInfo;
|
||||
use crate::token_data::KnownPlan;
|
||||
use crate::token_data::PlanType;
|
||||
use crate::token_data::parse_id_token;
|
||||
use base64::Engine;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z";
|
||||
|
||||
// moved to integration tests in tests/api_key_login.rs
|
||||
|
||||
#[test]
|
||||
fn loads_from_env_var_if_env_var_exists() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_var = std::env::var(crate::OPENAI_API_KEY_ENV_VAR);
|
||||
if let Ok(env_var) = env_var {
|
||||
let auth = load_auth(dir.path(), true).unwrap().unwrap();
|
||||
assert_eq!(auth.mode, AuthMode::ApiKey);
|
||||
assert_eq!(auth.api_key, Some(env_var));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
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,
|
||||
auth_dot_json,
|
||||
auth_file: _,
|
||||
} = load_auth(codex_home.path(), false).unwrap().unwrap();
|
||||
assert_eq!(None, api_key);
|
||||
assert_eq!(AuthMode::ChatGPT, mode);
|
||||
|
||||
let guard = auth_dot_json.lock().unwrap();
|
||||
let actual = guard.as_ref().expect("AuthDotJson should exist");
|
||||
assert_eq!(actual.openai_api_key, None);
|
||||
let tokens = actual.tokens.as_ref().expect("tokens should exist");
|
||||
assert_eq!(
|
||||
tokens.id_token,
|
||||
IdTokenInfo {
|
||||
email: Some("user@example.com".to_string()),
|
||||
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
|
||||
}
|
||||
);
|
||||
assert_eq!(tokens.access_token, "test-access-token".to_string());
|
||||
assert_eq!(tokens.refresh_token, "test-refresh-token".to_string());
|
||||
assert_eq!(tokens.account_id, None);
|
||||
assert_eq!(
|
||||
actual.last_refresh,
|
||||
Some(
|
||||
chrono::DateTime::parse_from_rfc3339(LAST_REFRESH)
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in
|
||||
/// [`TokenData::is_plan_that_should_use_api_key`], it should use
|
||||
/// [`AuthMode::ChatGPT`].
|
||||
#[tokio::test]
|
||||
async fn pro_account_with_api_key_still_uses_chatgpt_auth() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: Some("sk-test-key".to_string()),
|
||||
chatgpt_plan_type: "pro".to_string(),
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let CodexAuth {
|
||||
api_key,
|
||||
mode,
|
||||
auth_dot_json,
|
||||
auth_file: _,
|
||||
} = load_auth(codex_home.path(), false).unwrap().unwrap();
|
||||
assert_eq!(None, api_key);
|
||||
assert_eq!(AuthMode::ChatGPT, mode);
|
||||
|
||||
let guard = auth_dot_json.lock().unwrap();
|
||||
let actual = guard.as_ref().expect("AuthDotJson should exist");
|
||||
assert_eq!(actual.openai_api_key, None);
|
||||
let tokens = actual.tokens.as_ref().expect("tokens should exist");
|
||||
assert_eq!(
|
||||
tokens.id_token,
|
||||
IdTokenInfo {
|
||||
email: Some("user@example.com".to_string()),
|
||||
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
|
||||
}
|
||||
);
|
||||
assert_eq!(tokens.access_token, "test-access-token".to_string());
|
||||
assert_eq!(tokens.refresh_token, "test-refresh-token".to_string());
|
||||
assert_eq!(tokens.account_id, None);
|
||||
assert_eq!(
|
||||
actual.last_refresh,
|
||||
Some(
|
||||
chrono::DateTime::parse_from_rfc3339(LAST_REFRESH)
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// If the OPENAI_API_KEY is set in auth.json and it is an enterprise
|
||||
/// account, then it should use [`AuthMode::ApiKey`].
|
||||
#[tokio::test]
|
||||
async fn enterprise_account_with_api_key_uses_chatgpt_auth() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: Some("sk-test-key".to_string()),
|
||||
chatgpt_plan_type: "enterprise".to_string(),
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let CodexAuth {
|
||||
api_key,
|
||||
mode,
|
||||
auth_dot_json,
|
||||
auth_file: _,
|
||||
} = load_auth(codex_home.path(), false).unwrap().unwrap();
|
||||
assert_eq!(Some("sk-test-key".to_string()), api_key);
|
||||
assert_eq!(AuthMode::ApiKey, mode);
|
||||
|
||||
let guard = auth_dot_json.lock().expect("should unwrap");
|
||||
assert!(guard.is_none(), "auth_dot_json should be None");
|
||||
}
|
||||
|
||||
struct AuthFileParams {
|
||||
openai_api_key: Option<String>,
|
||||
chatgpt_plan_type: String,
|
||||
}
|
||||
|
||||
fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result<()> {
|
||||
let auth_file = get_auth_file(codex_home);
|
||||
#[derive(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",
|
||||
"email_verified": true,
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53",
|
||||
"chatgpt_plan_type": params.chatgpt_plan_type,
|
||||
"chatgpt_user_id": "user-12345",
|
||||
"user_id": "user-12345",
|
||||
}
|
||||
});
|
||||
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
|
||||
let header_b64 = b64(&serde_json::to_vec(&header)?);
|
||||
let payload_b64 = b64(&serde_json::to_vec(&payload)?);
|
||||
let signature_b64 = b64(b"sig");
|
||||
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||
|
||||
let auth_json_data = json!({
|
||||
"OPENAI_API_KEY": params.openai_api_key,
|
||||
"tokens": {
|
||||
"id_token": fake_jwt,
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token"
|
||||
},
|
||||
"last_refresh": LAST_REFRESH,
|
||||
});
|
||||
let auth_json = serde_json::to_string_pretty(&auth_json_data)?;
|
||||
std::fs::write(auth_file, auth_json)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_token_info_handles_missing_fields() {
|
||||
// Payload without email or plan should yield None values.
|
||||
let header = serde_json::json!({"alg": "none", "typ": "JWT"});
|
||||
let payload = serde_json::json!({"sub": "123"});
|
||||
let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&header).unwrap());
|
||||
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&payload).unwrap());
|
||||
let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig");
|
||||
let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||
|
||||
let info = parse_id_token(&jwt).expect("should parse");
|
||||
assert!(info.email.is_none());
|
||||
assert!(info.chatgpt_plan_type.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_api_key_from_auth_json() {
|
||||
let dir = tempdir().unwrap();
|
||||
let auth_file = dir.path().join("auth.json");
|
||||
std::fs::write(
|
||||
auth_file,
|
||||
r#"
|
||||
{
|
||||
"OPENAI_API_KEY": "sk-test-key",
|
||||
"tokens": null,
|
||||
"last_refresh": null
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let auth = load_auth(dir.path(), false).unwrap().unwrap();
|
||||
assert_eq!(auth.mode, AuthMode::ApiKey);
|
||||
assert_eq!(auth.api_key, Some("sk-test-key".to_string()));
|
||||
|
||||
assert!(auth.get_token_data().await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logout_removes_auth_file() -> Result<(), std::io::Error> {
|
||||
let dir = tempdir()?;
|
||||
crate::auth_store::login_with_api_key(dir.path(), "sk-test-key")?;
|
||||
assert!(dir.path().join("auth.json").exists());
|
||||
let removed = logout(dir.path())?;
|
||||
assert!(removed);
|
||||
assert!(!dir.path().join("auth.json").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_tokens_preserves_id_token_as_string() {
|
||||
let dir = tempdir().unwrap();
|
||||
let auth_file = crate::auth_store::get_auth_file(dir.path());
|
||||
|
||||
// Write an initial auth.json with a tokens object
|
||||
let initial = serde_json::json!({
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"id_token": "old-id-token",
|
||||
"access_token": "a1",
|
||||
"refresh_token": "r1"
|
||||
},
|
||||
"last_refresh": LAST_REFRESH
|
||||
});
|
||||
std::fs::write(&auth_file, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
|
||||
|
||||
// Build a valid-looking JWT (URL-safe base64 header.payload.signature)
|
||||
#[derive(Serialize)]
|
||||
struct Header {
|
||||
alg: &'static str,
|
||||
typ: &'static str,
|
||||
}
|
||||
let header = Header {
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
};
|
||||
let payload = serde_json::json!({});
|
||||
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");
|
||||
let new_id = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||
// Call update_tokens with a new id_token
|
||||
let _ = crate::auth_store::update_tokens(&auth_file, new_id.clone(), None, None).unwrap();
|
||||
|
||||
// Read raw file and ensure id_token is still a string, equal to what we wrote
|
||||
let raw = std::fs::read_to_string(&auth_file).unwrap();
|
||||
let val: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(val["tokens"]["id_token"].as_str(), Some(new_id.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_auth_json_is_python_compatible_shape() {
|
||||
let dir = tempdir().unwrap();
|
||||
let id_token = {
|
||||
#[derive(Serialize)]
|
||||
struct Header {
|
||||
alg: &'static str,
|
||||
typ: &'static str,
|
||||
}
|
||||
let header = Header {
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
};
|
||||
let payload = serde_json::json!({"sub": "123"});
|
||||
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}")
|
||||
};
|
||||
|
||||
let tokens = crate::token_data::TokenData::from_raw(
|
||||
id_token.clone(),
|
||||
"a1".to_string(),
|
||||
"r1".to_string(),
|
||||
Some("acc".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
let auth = crate::auth_store::AuthDotJson {
|
||||
openai_api_key: Some("sk-test".to_string()),
|
||||
tokens: Some(tokens),
|
||||
last_refresh: Some(chrono::Utc::now()),
|
||||
};
|
||||
crate::auth_store::write_auth_json(&crate::auth_store::get_auth_file(dir.path()), &auth)
|
||||
.unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(dir.path().join("auth.json")).unwrap();
|
||||
let val: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
|
||||
assert_eq!(val["OPENAI_API_KEY"].as_str(), Some("sk-test"));
|
||||
assert!(val["last_refresh"].as_str().is_some());
|
||||
assert!(val["tokens"].is_object());
|
||||
assert_eq!(val["tokens"]["id_token"].as_str(), Some(id_token.as_str()));
|
||||
assert_eq!(val["tokens"]["access_token"].as_str(), Some("a1"));
|
||||
assert_eq!(val["tokens"]["refresh_token"].as_str(), Some("r1"));
|
||||
assert_eq!(val["tokens"]["account_id"].as_str(), Some("acc"));
|
||||
}
|
||||
@@ -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()
|
||||
22
codex-rs/login/src/pkce.rs
Normal file
22
codex-rs/login/src/pkce.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use base64::Engine as _;
|
||||
use rand::RngCore;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PkceCodes {
|
||||
pub(crate) code_verifier: String,
|
||||
pub(crate) code_challenge: String,
|
||||
}
|
||||
|
||||
pub(crate) fn generate_pkce() -> PkceCodes {
|
||||
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,
|
||||
}
|
||||
}
|
||||
48
codex-rs/login/src/refresh.rs
Normal file
48
codex-rs/login/src/refresh.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest {
|
||||
client_id: &'static str,
|
||||
grant_type: &'static str,
|
||||
refresh_token: String,
|
||||
scope: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub(crate) struct RefreshResponse {
|
||||
pub(crate) id_token: String,
|
||||
pub(crate) access_token: Option<String>,
|
||||
pub(crate) refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn try_refresh_token(refresh_token: String) -> std::io::Result<RefreshResponse> {
|
||||
let refresh_request = RefreshRequest {
|
||||
client_id: crate::CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
scope: "openid profile email",
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://auth.openai.com/oauth/token")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&refresh_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let refresh_response = response
|
||||
.json::<RefreshResponse>()
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
Ok(refresh_response)
|
||||
} else {
|
||||
Err(std::io::Error::other(format!(
|
||||
"Failed to refresh token: {}",
|
||||
response.status()
|
||||
)))
|
||||
}
|
||||
}
|
||||
466
codex-rs/login/src/server.rs
Normal file
466
codex-rs/login/src/server.rs
Normal file
@@ -0,0 +1,466 @@
|
||||
use rand::RngCore;
|
||||
use reqwest::blocking::Client;
|
||||
use std::collections::HashMap;
|
||||
use std::net::TcpListener;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(feature = "http-e2e-tests")]
|
||||
use std::time::Duration;
|
||||
use tiny_http::Header;
|
||||
use tiny_http::Method;
|
||||
use tiny_http::Response;
|
||||
use tiny_http::Server;
|
||||
use url::Url;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::auth_store::AuthDotJson;
|
||||
use crate::auth_store::get_auth_file;
|
||||
use crate::auth_store::write_auth_json;
|
||||
use crate::pkce::generate_pkce;
|
||||
use crate::success_url::build_success_url;
|
||||
use crate::token_data::TokenData;
|
||||
use crate::token_data::extract_login_context_from_tokens;
|
||||
use tracing::error;
|
||||
use tracing::trace;
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 1455;
|
||||
pub const DEFAULT_ISSUER: &str = "https://auth.openai.com";
|
||||
|
||||
pub const LOGIN_SUCCESS_HTML: &str = include_str!("./success_page.html");
|
||||
pub const LOGIN_ERROR_HTML: &str = include_str!("./error_page.html");
|
||||
|
||||
fn render_error_html(message: &str) -> String {
|
||||
LOGIN_ERROR_HTML.replace("%%MESSAGE%%", html_escape::encode_text(message).as_ref())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LoginServerStatus {
|
||||
Url(String),
|
||||
Completed,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LoginServerOptions {
|
||||
pub codex_home: PathBuf,
|
||||
pub client_id: String,
|
||||
pub issuer: String,
|
||||
pub port: u16,
|
||||
pub open_browser: bool,
|
||||
pub expose_state_endpoint: bool,
|
||||
pub testing_timeout_secs: Option<u64>,
|
||||
pub port_sender: Option<std::sync::mpsc::Sender<u16>>,
|
||||
pub status_sender: Option<std::sync::mpsc::Sender<LoginServerStatus>>,
|
||||
pub cancel_receiver: Option<std::sync::mpsc::Receiver<()>>,
|
||||
}
|
||||
|
||||
impl LoginServerOptions {
|
||||
pub fn for_cli(codex_home: &Path, client_id: &str) -> Self {
|
||||
Self {
|
||||
codex_home: codex_home.to_path_buf(),
|
||||
client_id: client_id.to_string(),
|
||||
issuer: DEFAULT_ISSUER.to_string(),
|
||||
port: DEFAULT_PORT,
|
||||
open_browser: true,
|
||||
expose_state_endpoint: false,
|
||||
testing_timeout_secs: None,
|
||||
port_sender: None,
|
||||
status_sender: None,
|
||||
cancel_receiver: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_ui(
|
||||
codex_home: &Path,
|
||||
client_id: &str,
|
||||
status_sender: std::sync::mpsc::Sender<LoginServerStatus>,
|
||||
cancel_receiver: std::sync::mpsc::Receiver<()>,
|
||||
port: u16,
|
||||
) -> Self {
|
||||
Self {
|
||||
codex_home: codex_home.to_path_buf(),
|
||||
client_id: client_id.to_string(),
|
||||
issuer: DEFAULT_ISSUER.to_string(),
|
||||
port,
|
||||
open_browser: true,
|
||||
expose_state_endpoint: false,
|
||||
testing_timeout_secs: None,
|
||||
port_sender: None,
|
||||
status_sender: Some(status_sender),
|
||||
cancel_receiver: Some(cancel_receiver),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PLATFORM_BASE: &str = "https://platform.openai.com";
|
||||
|
||||
fn default_url_base(port: u16) -> String {
|
||||
format!("http://localhost:{port}")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn run_local_login_server(codex_home: &Path, client_id: &str) -> std::io::Result<()> {
|
||||
let opts = LoginServerOptions::for_cli(codex_home, client_id);
|
||||
run_local_login_server_with_options(opts)
|
||||
}
|
||||
|
||||
pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std::io::Result<()> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", opts.port))
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?
|
||||
.port();
|
||||
opts.port = actual_port;
|
||||
|
||||
if let Some(tx) = &opts.port_sender {
|
||||
let _ = tx.send(actual_port);
|
||||
}
|
||||
|
||||
let server =
|
||||
Server::from_listener(listener, None).map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
|
||||
let issuer = opts.issuer.clone();
|
||||
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!("{url_base}/auth/callback");
|
||||
let auth_url_str = format!("{issuer}/oauth/authorize");
|
||||
let mut auth_url =
|
||||
Url::parse(&auth_url_str).map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
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);
|
||||
|
||||
if opts.status_sender.is_none() {
|
||||
eprintln!("Starting local login server on {url_base}");
|
||||
}
|
||||
if let Some(tx) = &opts.status_sender {
|
||||
let _ = tx.send(LoginServerStatus::Url(auth_url.as_str().to_string()));
|
||||
}
|
||||
if opts.open_browser {
|
||||
let _ = webbrowser::open(auth_url.as_str());
|
||||
}
|
||||
if opts.status_sender.is_none() {
|
||||
eprintln!(
|
||||
". If your browser did not open, navigate to this URL to authenticate: \n\n{auth_url}"
|
||||
);
|
||||
}
|
||||
|
||||
// If a testing timeout is configured, schedule an internal exit request so tests don't hang CI.
|
||||
#[cfg(feature = "http-e2e-tests")]
|
||||
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"));
|
||||
});
|
||||
}
|
||||
|
||||
// If cancellation is requested via cancel_receiver, trigger server shutdown by hitting /success
|
||||
if let Some(rx) = opts.cancel_receiver.take() {
|
||||
let port = opts.port;
|
||||
std::thread::spawn(move || {
|
||||
let _ = rx.recv();
|
||||
let _ = reqwest::blocking::get(format!("http://127.0.0.1:{port}/success"));
|
||||
});
|
||||
}
|
||||
|
||||
// Main request loop
|
||||
'outer: loop {
|
||||
let request = match server.recv() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Err(std::io::Error::other(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
trace!("{} {}", request.method().as_str(), request.url());
|
||||
|
||||
match (request.method().clone(), path.as_str()) {
|
||||
(Method::Get, "/success") => {
|
||||
let mut resp = Response::from_string(LOGIN_SUCCESS_HTML).with_status_code(200);
|
||||
if let Ok(h) =
|
||||
Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
|
||||
{
|
||||
resp.add_header(h);
|
||||
}
|
||||
if let Err(e) = request.respond(resp) {
|
||||
error!("failed to respond to /success: {e}");
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
(Method::Get, "/__test/exit") => {
|
||||
if let Err(e) = request.respond(Response::from_string("bye").with_status_code(200))
|
||||
{
|
||||
error!("failed to respond to /__test/exit: {e}");
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
// Test-only helper to retrieve the current state, enabled via options.
|
||||
#[cfg(feature = "http-e2e-tests")]
|
||||
(Method::Get, "/__test/state") if opts.expose_state_endpoint => {
|
||||
let mut resp = Response::from_string(state.clone()).with_status_code(200);
|
||||
if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]) {
|
||||
resp.add_header(h);
|
||||
}
|
||||
if let Err(e) = request.respond(resp) {
|
||||
error!("failed to respond to /__test/state: {e}");
|
||||
}
|
||||
}
|
||||
(Method::Get, "/auth/callback") => {
|
||||
// Parse query params
|
||||
let params: HashMap<String, String> =
|
||||
form_urlencoded::parse(query.as_deref().unwrap_or("").as_bytes())
|
||||
.into_owned()
|
||||
.collect();
|
||||
|
||||
// Preserve explicit error messages for tests
|
||||
if params.get("state").map(|s| s.as_str()) != Some(state.as_str()) {
|
||||
let mut resp =
|
||||
Response::from_string(render_error_html("State parameter mismatch"))
|
||||
.with_status_code(400);
|
||||
if let Ok(h) =
|
||||
Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
|
||||
{
|
||||
resp.add_header(h);
|
||||
}
|
||||
if let Err(e) = request.respond(resp) {
|
||||
error!("failed to respond to state mismatch: {e}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let code_opt = params.get("code").map(|s| s.as_str());
|
||||
if code_opt.map(|s| s.is_empty()).unwrap_or(true) {
|
||||
let mut resp =
|
||||
Response::from_string(render_error_html("Missing authorization code"))
|
||||
.with_status_code(400);
|
||||
if let Ok(h) =
|
||||
Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
|
||||
{
|
||||
resp.add_header(h);
|
||||
}
|
||||
if let Err(e) = request.respond(resp) {
|
||||
error!("failed to respond to missing code: {e}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delegate to shared headless callback handler
|
||||
let http = DefaultHttp::default();
|
||||
match process_callback_headless(
|
||||
&opts,
|
||||
&state,
|
||||
&state,
|
||||
code_opt,
|
||||
&pkce.code_verifier,
|
||||
&http,
|
||||
) {
|
||||
Ok(outcome) => {
|
||||
let mut resp = Response::empty(302);
|
||||
if let Ok(h) =
|
||||
Header::from_bytes(&b"Location"[..], outcome.success_url.as_str())
|
||||
{
|
||||
resp.add_header(h);
|
||||
}
|
||||
if let Err(e) = request.respond(resp) {
|
||||
error!("failed to respond redirect to success: {e}");
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let mut resp =
|
||||
Response::from_string(render_error_html("Token exchange failed"))
|
||||
.with_status_code(500);
|
||||
if let Ok(h) = Header::from_bytes(
|
||||
&b"Content-Type"[..],
|
||||
&b"text/html; charset=utf-8"[..],
|
||||
) {
|
||||
resp.add_header(h);
|
||||
}
|
||||
if let Err(e) = request.respond(resp) {
|
||||
error!("failed to respond to token exchange failure: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Err(e) = request
|
||||
.respond(Response::from_string("Endpoint not supported").with_status_code(404))
|
||||
{
|
||||
error!("failed to respond 404: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(tx) = &opts.status_sender {
|
||||
let _ = tx.send(LoginServerStatus::Completed);
|
||||
}
|
||||
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 {
|
||||
Self(Client::new())
|
||||
}
|
||||
}
|
||||
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::other(e.to_string()))?;
|
||||
let val = resp
|
||||
.json::<serde_json::Value>()
|
||||
.map_err(|e| std::io::Error::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::other(e.to_string()))?;
|
||||
let val = resp
|
||||
.json::<serde_json::Value>()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
Ok(val)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenExchange {
|
||||
id_token: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
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 TokenExchange {
|
||||
id_token,
|
||||
access_token,
|
||||
refresh_token,
|
||||
} = serde_json::from_value(tokens_val)
|
||||
.map_err(|e| std::io::Error::other(format!("invalid token response: {e}")))?;
|
||||
if id_token.is_empty() || access_token.is_empty() || refresh_token.is_empty() {
|
||||
return Err(std::io::Error::other("token exchange failed"));
|
||||
}
|
||||
|
||||
let (account_id, org_id, project_id, needs_setup, plan_type) =
|
||||
extract_login_context_from_tokens(&id_token, &access_token);
|
||||
|
||||
let api_key = None;
|
||||
|
||||
let tokens_struct = TokenData::from_raw(
|
||||
id_token.clone(),
|
||||
access_token.clone(),
|
||||
refresh_token.clone(),
|
||||
account_id,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let auth = AuthDotJson {
|
||||
openai_api_key: api_key.clone(),
|
||||
tokens: Some(tokens_struct),
|
||||
last_refresh: Some(chrono::Utc::now()),
|
||||
};
|
||||
let auth_file = get_auth_file(&opts.codex_home);
|
||||
write_auth_json(&auth_file, &auth)?;
|
||||
|
||||
// Intentionally not redeeming credits here
|
||||
|
||||
let base = default_url_base(opts.port);
|
||||
let platform_url = PLATFORM_BASE;
|
||||
let success_url = build_success_url(
|
||||
&base,
|
||||
Some(&id_token),
|
||||
org_id.as_deref(),
|
||||
project_id.as_deref(),
|
||||
plan_type.as_deref(),
|
||||
needs_setup,
|
||||
platform_url,
|
||||
)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
|
||||
Ok(HeadlessOutcome {
|
||||
success_url: success_url.to_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>
|
||||
|
||||
|
||||
32
codex-rs/login/src/success_url.rs
Normal file
32
codex-rs/login/src/success_url.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use url::Url;
|
||||
|
||||
pub(crate) fn build_success_url(
|
||||
url_base: &str,
|
||||
id_token: Option<&str>,
|
||||
org_id: Option<&str>,
|
||||
project_id: Option<&str>,
|
||||
plan_type: Option<&str>,
|
||||
needs_setup: bool,
|
||||
platform_url: &str,
|
||||
) -> Result<Url, url::ParseError> {
|
||||
let mut success_url = Url::parse(&format!("{url_base}/success"))?;
|
||||
if let Some(id) = id_token {
|
||||
success_url.query_pairs_mut().append_pair("id_token", id);
|
||||
}
|
||||
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);
|
||||
Ok(success_url)
|
||||
}
|
||||
@@ -3,23 +3,51 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(try_from = "TokenDataDe")]
|
||||
pub struct TokenData {
|
||||
/// Flat info parsed from the JWT in auth.json.
|
||||
#[serde(deserialize_with = "deserialize_id_token")]
|
||||
/// Flat info parsed from the JWT in auth.json (not serialized).
|
||||
#[serde(skip)]
|
||||
pub id_token: IdTokenInfo,
|
||||
|
||||
/// Raw JWT string used for serialization as `tokens.id_token` on disk.
|
||||
#[serde(rename = "id_token")]
|
||||
pub id_token_raw: String,
|
||||
/// This is a JWT.
|
||||
pub access_token: String,
|
||||
|
||||
pub refresh_token: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
impl PartialEq for TokenData {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id_token == other.id_token
|
||||
&& self.access_token == other.access_token
|
||||
&& self.refresh_token == other.refresh_token
|
||||
&& self.account_id == other.account_id
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for TokenData {}
|
||||
/// Returns true if this is a plan that should use the traditional
|
||||
/// "metered" billing via an API key.
|
||||
impl TokenData {
|
||||
/// Returns true if this is a plan that should use the traditional
|
||||
/// "metered" billing via an API key.
|
||||
pub fn from_raw(
|
||||
id_token_raw: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
account_id: Option<String>,
|
||||
) -> Result<Self, IdTokenInfoError> {
|
||||
let id_token = parse_id_token(&id_token_raw)?;
|
||||
Ok(Self {
|
||||
id_token,
|
||||
id_token_raw,
|
||||
access_token,
|
||||
refresh_token,
|
||||
account_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_plan_that_should_use_api_key(&self) -> bool {
|
||||
self.id_token
|
||||
.chatgpt_plan_type
|
||||
@@ -28,13 +56,35 @@ impl TokenData {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenDataDe {
|
||||
#[serde(rename = "id_token")]
|
||||
id_token_raw: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
#[serde(default)]
|
||||
account_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<TokenDataDe> for TokenData {
|
||||
type Error = IdTokenInfoError;
|
||||
|
||||
fn try_from(de: TokenDataDe) -> Result<Self, Self::Error> {
|
||||
let id_token = parse_id_token(&de.id_token_raw)?;
|
||||
Ok(TokenData {
|
||||
id_token,
|
||||
id_token_raw: de.id_token_raw,
|
||||
access_token: de.access_token,
|
||||
refresh_token: de.refresh_token,
|
||||
account_id: de.account_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat subset of useful claims in id_token from auth.json.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -88,19 +138,7 @@ pub(crate) enum KnownPlan {
|
||||
Edu,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IdClaims {
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(rename = "https://api.openai.com/auth", default)]
|
||||
auth: Option<AuthClaims>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthClaims {
|
||||
#[serde(default)]
|
||||
chatgpt_plan_type: Option<PlanType>,
|
||||
}
|
||||
// Removed duplicate IdClaims/AuthClaims in favor of unified helpers below
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum IdTokenInfoError {
|
||||
@@ -113,28 +151,105 @@ pub enum IdTokenInfoError {
|
||||
}
|
||||
|
||||
pub(crate) fn parse_id_token(id_token: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
|
||||
// JWT format: header.payload.signature
|
||||
let mut parts = id_token.split('.');
|
||||
let (_header_b64, payload_b64, _sig_b64) = 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),
|
||||
_ => return Err(IdTokenInfoError::InvalidFormat),
|
||||
};
|
||||
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?;
|
||||
let claims: IdClaims = serde_json::from_slice(&payload_bytes)?;
|
||||
|
||||
// Reuse the generic JWT parsing helpers to extract fields
|
||||
let payload = decode_jwt_payload(id_token).ok_or(IdTokenInfoError::InvalidFormat)?;
|
||||
// Reuse AuthOuterClaims instead of a local struct to avoid duplication
|
||||
let claims: AuthOuterClaims = serde_json::from_slice(&payload)?;
|
||||
Ok(IdTokenInfo {
|
||||
email: claims.email,
|
||||
chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type),
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_id_token<'de, D>(deserializer: D) -> Result<IdTokenInfo, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
parse_id_token(&s).map_err(serde::de::Error::custom)
|
||||
// -------- Helpers for parsing OpenAI auth claims from arbitrary JWTs --------
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct AuthOuterClaims {
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(rename = "https://api.openai.com/auth", default)]
|
||||
auth: Option<AuthInnerClaims>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Clone)]
|
||||
struct AuthInnerClaims {
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
#[serde(default)]
|
||||
organization_id: Option<String>,
|
||||
#[serde(default)]
|
||||
project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
completed_platform_onboarding: Option<bool>,
|
||||
#[serde(default)]
|
||||
is_org_owner: Option<bool>,
|
||||
#[serde(default)]
|
||||
chatgpt_plan_type: Option<PlanType>,
|
||||
}
|
||||
|
||||
fn decode_jwt_payload(token: &str) -> Option<Vec<u8>> {
|
||||
let mut parts = token.split('.');
|
||||
let _header = parts.next();
|
||||
let payload_b64 = parts.next();
|
||||
let _sig = parts.next();
|
||||
payload_b64.and_then(|p| {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(p)
|
||||
.ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_auth_inner_claims(token: &str) -> AuthInnerClaims {
|
||||
decode_jwt_payload(token)
|
||||
.and_then(|bytes| serde_json::from_slice::<AuthOuterClaims>(&bytes).ok())
|
||||
.and_then(|o| o.auth)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extracts commonly used claims from ID and access tokens.
|
||||
/// - account_id is taken from the ID token.
|
||||
/// - org_id/project_id prefer ID token, falling back to access token.
|
||||
/// - plan_type comes from the access token (as lowercase string).
|
||||
/// - needs_setup is computed from (completed_platform_onboarding, is_org_owner)
|
||||
pub(crate) fn extract_login_context_from_tokens(
|
||||
id_token: &str,
|
||||
access_token: &str,
|
||||
) -> (
|
||||
Option<String>, // account_id
|
||||
Option<String>, // org_id
|
||||
Option<String>, // project_id
|
||||
bool, // needs_setup
|
||||
Option<String>, // plan_type
|
||||
) {
|
||||
let id_inner = parse_auth_inner_claims(id_token);
|
||||
let access_inner = parse_auth_inner_claims(access_token);
|
||||
|
||||
let account_id = id_inner.chatgpt_account_id.clone();
|
||||
let org_id = id_inner
|
||||
.organization_id
|
||||
.clone()
|
||||
.or_else(|| access_inner.organization_id.clone());
|
||||
let project_id = id_inner
|
||||
.project_id
|
||||
.clone()
|
||||
.or_else(|| access_inner.project_id.clone());
|
||||
|
||||
let completed_onboarding = id_inner
|
||||
.completed_platform_onboarding
|
||||
.or(access_inner.completed_platform_onboarding)
|
||||
.unwrap_or(false);
|
||||
let is_org_owner = id_inner
|
||||
.is_org_owner
|
||||
.or(access_inner.is_org_owner)
|
||||
.unwrap_or(false);
|
||||
let needs_setup = !completed_onboarding && is_org_owner;
|
||||
|
||||
let plan_type = access_inner
|
||||
.chatgpt_plan_type
|
||||
.as_ref()
|
||||
.map(PlanType::as_string);
|
||||
|
||||
(account_id, org_id, project_id, needs_setup, plan_type)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
12
codex-rs/login/tests/api_key_login.rs
Normal file
12
codex-rs/login/tests/api_key_login.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn writes_api_key_and_loads_auth() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dir = tempdir()?;
|
||||
codex_login::login_with_api_key(dir.path(), "sk-test-key")?;
|
||||
let auth = codex_login::CodexAuth::from_codex_home(dir.path())?
|
||||
.ok_or_else(|| std::io::Error::other("expected Some(auth)"))?;
|
||||
assert_eq!(auth.mode, codex_login::AuthMode::ApiKey);
|
||||
assert_eq!(auth.get_token().await?.as_str(), "sk-test-key");
|
||||
Ok(())
|
||||
}
|
||||
10
codex-rs/login/tests/common/mod.rs
Normal file
10
codex-rs/login/tests/common/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
pub 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}")
|
||||
}
|
||||
157
codex-rs/login/tests/headless.rs
Normal file
157
codex-rs/login/tests/headless.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
#![expect(clippy::unwrap_used)]
|
||||
use codex_login::LoginServerOptions;
|
||||
use codex_login::process_callback_headless;
|
||||
use serde_json::json;
|
||||
mod common;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use tempfile::TempDir;
|
||||
|
||||
type FormCapture = (String, Vec<(String, String)>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockHttp {
|
||||
forms: RefCell<Vec<FormCapture>>,
|
||||
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::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::other("no reply"))
|
||||
}
|
||||
}
|
||||
|
||||
use common::make_fake_jwt;
|
||||
|
||||
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,
|
||||
expose_state_endpoint: false,
|
||||
testing_timeout_secs: None,
|
||||
port_sender: None,
|
||||
status_sender: None,
|
||||
cancel_receiver: None,
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
}));
|
||||
|
||||
let outcome =
|
||||
process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
|
||||
assert!(outcome.success_url.contains("/success"));
|
||||
let auth_path = codex_login::get_auth_file(tmp.path());
|
||||
let auth = codex_login::try_read_auth_json(&auth_path).unwrap();
|
||||
assert!(auth.openai_api_key.is_none());
|
||||
}
|
||||
|
||||
// 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) (Removed) Credit redemption is no longer attempted
|
||||
|
||||
// 6) ID-token fallback for org/project/flags
|
||||
#[test]
|
||||
fn headless_id_token_fallback_for_org_and_project() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let opts = default_opts(&tmp);
|
||||
let http = MockHttp::default();
|
||||
// Code exchange: put org/project/flags into ID token; plan_type into access
|
||||
http.queue(json!({
|
||||
"id_token": make_fake_jwt(json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc",
|
||||
"organization_id": "id-org",
|
||||
"project_id": "id-proj",
|
||||
"completed_platform_onboarding": true,
|
||||
"is_org_owner": false
|
||||
}
|
||||
})),
|
||||
"access_token": make_fake_jwt(json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_plan_type": "plus"
|
||||
}
|
||||
})),
|
||||
"refresh_token": "r1"
|
||||
}));
|
||||
|
||||
let outcome =
|
||||
process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
|
||||
assert!(outcome.success_url.contains("org_id=id-org"));
|
||||
assert!(outcome.success_url.contains("project_id=id-proj"));
|
||||
}
|
||||
489
codex-rs/login/tests/server.rs
Normal file
489
codex-rs/login/tests/server.rs
Normal file
@@ -0,0 +1,489 @@
|
||||
#![cfg(feature = "http-e2e-tests")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod common;
|
||||
use codex_login::LoginServerOptions;
|
||||
use codex_login::run_local_login_server_with_options;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
async fn start_mock_oauth_server(behavior: MockBehavior) -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
match behavior {
|
||||
MockBehavior::Noop => {}
|
||||
MockBehavior::Success => {
|
||||
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"
|
||||
});
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.set_body_json(payload),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
MockBehavior::SuccessTwice => {
|
||||
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"
|
||||
});
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.set_body_json(payload),
|
||||
)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
MockBehavior::SuccessNeedsSetup => {
|
||||
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-2",
|
||||
"project_id": "proj-2",
|
||||
"completed_platform_onboarding": false,
|
||||
"is_org_owner": true,
|
||||
"chatgpt_plan_type": "pro"
|
||||
}
|
||||
}));
|
||||
let payload = serde_json::json!({
|
||||
"id_token": id_token,
|
||||
"access_token": access_token,
|
||||
"refresh_token": "refresh-1"
|
||||
});
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.set_body_json(payload),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
MockBehavior::SuccessIdClaimsOrgProject => {
|
||||
let id_token = make_fake_jwt(serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-3",
|
||||
"organization_id": "org-id",
|
||||
"project_id": "proj-id",
|
||||
"completed_platform_onboarding": true,
|
||||
"is_org_owner": false
|
||||
}
|
||||
}));
|
||||
let access_token = make_fake_jwt(serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_plan_type": "plus"
|
||||
}
|
||||
}));
|
||||
let payload = serde_json::json!({
|
||||
"id_token": id_token,
|
||||
"access_token": access_token,
|
||||
"refresh_token": "refresh-1"
|
||||
});
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.set_body_json(payload),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
MockBehavior::TokenError => {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
MockBehavior::MissingOrgSkipExchange => {
|
||||
let id_token = make_fake_jwt(serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-4"
|
||||
}
|
||||
}));
|
||||
let access_token = make_fake_jwt(serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_plan_type": "plus"
|
||||
}
|
||||
}));
|
||||
let payload = serde_json::json!({
|
||||
"id_token": id_token,
|
||||
"access_token": access_token,
|
||||
"refresh_token": "refresh-4"
|
||||
});
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.set_body_json(payload),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
server
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum MockBehavior {
|
||||
Noop,
|
||||
Success,
|
||||
SuccessTwice,
|
||||
SuccessNeedsSetup,
|
||||
SuccessIdClaimsOrgProject,
|
||||
TokenError,
|
||||
MissingOrgSkipExchange,
|
||||
// Old token-exchange fallback behaviors removed
|
||||
}
|
||||
|
||||
use common::make_fake_jwt;
|
||||
|
||||
fn spawn_login_server_and_wait(
|
||||
issuer: String,
|
||||
codex_home: &tempfile::TempDir,
|
||||
) -> (std::thread::JoinHandle<std::io::Result<()>>, u16) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let opts = LoginServerOptions {
|
||||
codex_home: codex_home.path().to_path_buf(),
|
||||
client_id: "test-client".to_string(),
|
||||
issuer,
|
||||
port: 0,
|
||||
open_browser: false,
|
||||
expose_state_endpoint: true,
|
||||
testing_timeout_secs: Some(5),
|
||||
port_sender: Some(tx),
|
||||
status_sender: None,
|
||||
cancel_receiver: None,
|
||||
};
|
||||
|
||||
let handle = thread::spawn(move || run_local_login_server_with_options(opts));
|
||||
let port = rx.recv().unwrap();
|
||||
wait_for_state_endpoint(port, Duration::from_secs(5));
|
||||
(handle, port)
|
||||
}
|
||||
|
||||
fn http_get(url: &str) -> (u16, String, Option<String>) {
|
||||
let agent = ureq::AgentBuilder::new().redirects(0).build();
|
||||
match agent.get(url).call() {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let location = resp.header("Location").map(|s| s.to_string());
|
||||
let body = resp.into_string().unwrap_or_default();
|
||||
(status, body, location)
|
||||
}
|
||||
Err(ureq::Error::Status(code, resp)) => {
|
||||
let location = resp.header("Location").map(|s| s.to_string());
|
||||
let body = resp.into_string().unwrap_or_default();
|
||||
(code, body, location)
|
||||
}
|
||||
Err(err) => panic!("http error: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_get_with_ct(url: &str) -> (u16, String, Option<String>) {
|
||||
let agent = ureq::AgentBuilder::new().redirects(0).build();
|
||||
match agent.get(url).call() {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let content_type = resp.header("content-type").map(|s| s.to_string());
|
||||
let body = resp.into_string().unwrap_or_default();
|
||||
(status, body, content_type)
|
||||
}
|
||||
Err(ureq::Error::Status(code, resp)) => {
|
||||
let content_type = resp.header("content-type").map(|s| s.to_string());
|
||||
let body = resp.into_string().unwrap_or_default();
|
||||
(code, body, content_type)
|
||||
}
|
||||
Err(err) => panic!("http error: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
#[tokio::test]
|
||||
async fn login_server_happy_path() {
|
||||
let server = start_mock_oauth_server(MockBehavior::SuccessTwice).await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = server.uri();
|
||||
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
|
||||
|
||||
// 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}");
|
||||
// First, capture redirect without following
|
||||
let (status, _body, location) = http_get(&cb_url);
|
||||
assert_eq!(status, 302);
|
||||
let location = location.expect("location header");
|
||||
assert!(location.contains("/success"));
|
||||
assert!(location.contains("needs_setup=false"));
|
||||
assert!(location.contains("plan_type=plus"));
|
||||
assert!(location.contains("org_id=org-1"));
|
||||
assert!(location.contains("project_id=proj-1"));
|
||||
// Now follow redirect (this will invoke the callback a second time)
|
||||
let (status, body) = http_get_follow_redirect(&cb_url);
|
||||
assert_eq!(status, 200);
|
||||
assert!(body.contains("Signed in to Codex CLI"));
|
||||
|
||||
handle.join().unwrap().unwrap();
|
||||
|
||||
// Verify auth.json written
|
||||
let auth_path = codex_login::get_auth_file(codex_home.path());
|
||||
let auth = codex_login::try_read_auth_json(&auth_path).unwrap();
|
||||
assert!(auth.openai_api_key.is_none());
|
||||
assert!(auth.tokens.as_ref().is_some());
|
||||
assert!(!auth.tokens.as_ref().unwrap().access_token.is_empty());
|
||||
}
|
||||
// 1b) needs_setup=true when onboarding incomplete and is_org_owner=true
|
||||
#[tokio::test]
|
||||
async fn login_server_needs_setup_true_and_params_present() {
|
||||
let server = start_mock_oauth_server(MockBehavior::SuccessNeedsSetup).await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = server.uri();
|
||||
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
|
||||
let state_url = format!("http://127.0.0.1:{port}/__test/state");
|
||||
let (_s, state, _) = http_get(&state_url);
|
||||
assert!(!state.is_empty());
|
||||
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
||||
let (status, _body, location) = http_get(&cb_url);
|
||||
assert_eq!(status, 302);
|
||||
let location = location.expect("location header");
|
||||
assert!(location.contains("needs_setup=true"));
|
||||
assert!(location.contains("plan_type=pro"));
|
||||
assert!(location.contains("org_id=org-2"));
|
||||
assert!(location.contains("project_id=proj-2"));
|
||||
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
||||
handle.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
// 1c) org/project from ID token only should appear in redirect (fallback logic)
|
||||
#[tokio::test]
|
||||
async fn login_server_id_token_fallback_for_org_and_project() {
|
||||
let server = start_mock_oauth_server(MockBehavior::SuccessIdClaimsOrgProject).await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = server.uri();
|
||||
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
|
||||
let state_url = format!("http://127.0.0.1:{port}/__test/state");
|
||||
let (_s, state, _) = http_get(&state_url);
|
||||
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
||||
let (status, _body, location) = http_get(&cb_url);
|
||||
assert_eq!(status, 302);
|
||||
let location = location.expect("location header");
|
||||
assert!(location.contains("org_id=org-id"));
|
||||
assert!(location.contains("project_id=proj-id"));
|
||||
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
||||
handle.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
// 1d) Missing org/project in claims -> skip token-exchange, persist tokens without API key, still success
|
||||
#[tokio::test]
|
||||
async fn login_server_skips_exchange_when_no_org_or_project() {
|
||||
let server = start_mock_oauth_server(MockBehavior::MissingOrgSkipExchange).await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = server.uri();
|
||||
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
|
||||
let state_url = format!("http://127.0.0.1:{port}/__test/state");
|
||||
let (_s, state, _) = http_get(&state_url);
|
||||
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
||||
let (status, _body, location) = http_get(&cb_url);
|
||||
assert_eq!(status, 302);
|
||||
let location = location.expect("location header");
|
||||
// No org_id/project_id in redirect
|
||||
assert!(!location.contains("org_id="));
|
||||
assert!(!location.contains("project_id="));
|
||||
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
||||
handle.join().unwrap().unwrap();
|
||||
|
||||
// Verify auth.json OPENAI_API_KEY is null
|
||||
let auth_path = codex_login::get_auth_file(codex_home.path());
|
||||
let auth = codex_login::try_read_auth_json(&auth_path).unwrap();
|
||||
assert!(auth.openai_api_key.is_none());
|
||||
}
|
||||
|
||||
//
|
||||
// 2) State mismatch returns 400 and server stays up
|
||||
#[tokio::test]
|
||||
async fn login_server_state_mismatch() {
|
||||
let server = start_mock_oauth_server(MockBehavior::Noop).await;
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = server.uri();
|
||||
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
|
||||
|
||||
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state=wrong");
|
||||
let (status, body, content_type) = http_get_with_ct(&cb_url);
|
||||
assert_eq!(status, 400);
|
||||
assert!(body.contains("State parameter mismatch"));
|
||||
assert!(
|
||||
content_type
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("text/html")
|
||||
);
|
||||
|
||||
// Stop server
|
||||
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
||||
handle.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
// 3) Missing code returns 400
|
||||
#[tokio::test]
|
||||
async fn login_server_missing_code() {
|
||||
let server = start_mock_oauth_server(MockBehavior::Noop).await;
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = server.uri();
|
||||
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
|
||||
|
||||
// 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, content_type) = http_get_with_ct(&cb_url);
|
||||
assert_eq!(status, 400);
|
||||
assert!(body.contains("Missing authorization code"));
|
||||
assert!(
|
||||
content_type
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("text/html")
|
||||
);
|
||||
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
||||
handle.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
// 4) Token endpoint error returns 500 (on code exchange) and server stays up
|
||||
#[tokio::test]
|
||||
async fn login_server_token_exchange_error() {
|
||||
let server = start_mock_oauth_server(MockBehavior::TokenError).await;
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = server.uri();
|
||||
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
|
||||
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, content_type) = http_get_with_ct(&cb_url);
|
||||
assert_eq!(status, 500);
|
||||
assert!(body.contains("Token exchange failed"));
|
||||
assert!(
|
||||
content_type
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("text/html")
|
||||
);
|
||||
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
||||
handle.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
// 5) Credit redemption errors do not block success
|
||||
#[tokio::test]
|
||||
async fn login_server_credit_redemption_best_effort() {
|
||||
// Mock behavior success for token endpoints
|
||||
let server = start_mock_oauth_server(MockBehavior::Success).await;
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = server.uri();
|
||||
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
|
||||
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().unwrap();
|
||||
// auth.json exists
|
||||
assert!(codex_login::get_auth_file(codex_home.path()).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));
|
||||
}
|
||||
}
|
||||
@@ -38,19 +38,20 @@ pub(crate) enum SignInState {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Used to manage the lifecycle of SpawnedLogin and FrameTicker and ensure they get cleaned up.
|
||||
/// Used to manage lifecycle of in-process login server and UI ticker.
|
||||
pub(crate) struct ContinueInBrowserState {
|
||||
login_child: Option<codex_login::SpawnedLogin>,
|
||||
join_handle: Option<std::thread::JoinHandle<std::io::Result<()>>>,
|
||||
cancel_tx: Option<std::sync::mpsc::Sender<()>>,
|
||||
_frame_ticker: Option<FrameTicker>,
|
||||
url_shared: std::sync::Arc<std::sync::Mutex<Option<String>>>,
|
||||
}
|
||||
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();
|
||||
}
|
||||
if let Some(tx) = self.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(handle) = self.join_handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,11 +187,8 @@ impl AuthModeWidget {
|
||||
let mut lines = vec![Line::from(spans), Line::from("")];
|
||||
|
||||
if let SignInState::ChatGptContinueInBrowser(state) = &self.sign_in_state {
|
||||
if let Some(url) = state
|
||||
.login_child
|
||||
.as_ref()
|
||||
.and_then(|child| child.get_login_url())
|
||||
{
|
||||
let url_opt = state.url_shared.lock().ok().and_then(|g| g.clone());
|
||||
if let Some(url) = url_opt {
|
||||
lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:"));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
@@ -291,22 +289,16 @@ impl AuthModeWidget {
|
||||
|
||||
fn start_chatgpt_login(&mut self) {
|
||||
self.error = None;
|
||||
match codex_login::spawn_login_with_chatgpt(&self.codex_home) {
|
||||
Ok(child) => {
|
||||
self.spawn_completion_poller(child.clone());
|
||||
self.sign_in_state =
|
||||
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
|
||||
login_child: Some(child),
|
||||
_frame_ticker: Some(FrameTicker::new(self.event_tx.clone())),
|
||||
});
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
Err(e) => {
|
||||
self.sign_in_state = SignInState::PickMode;
|
||||
self.error = Some(e.to_string());
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
}
|
||||
let (handle, status_rx, cancel_tx) = codex_login::spawn_login_in_process(&self.codex_home);
|
||||
let url_shared = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
self.spawn_status_listener(status_rx, url_shared.clone());
|
||||
self.sign_in_state = SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
|
||||
join_handle: Some(handle),
|
||||
cancel_tx: Some(cancel_tx),
|
||||
_frame_ticker: Some(FrameTicker::new(self.event_tx.clone())),
|
||||
url_shared,
|
||||
});
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
|
||||
/// TODO: Read/write from the correct hierarchy config overrides + auth json + OPENAI_API_KEY.
|
||||
@@ -319,37 +311,26 @@ impl AuthModeWidget {
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
|
||||
fn spawn_completion_poller(&self, child: codex_login::SpawnedLogin) {
|
||||
let child_arc = child.child.clone();
|
||||
let stderr_buf = child.stderr.clone();
|
||||
fn spawn_status_listener(
|
||||
&self,
|
||||
status_rx: std::sync::mpsc::Receiver<codex_login::LoginServerStatus>,
|
||||
url_shared: std::sync::Arc<std::sync::Mutex<Option<String>>>,
|
||||
) {
|
||||
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),
|
||||
while let Ok(status) = status_rx.recv() {
|
||||
match status {
|
||||
codex_login::LoginServerStatus::Url(u) => {
|
||||
if let Ok(mut g) = url_shared.lock() {
|
||||
*g = Some(u);
|
||||
}
|
||||
} else {
|
||||
Some(false)
|
||||
event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
};
|
||||
if let Some(success) = done {
|
||||
if success {
|
||||
codex_login::LoginServerStatus::Completed => {
|
||||
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)));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user