works, fmt, and clippy

This commit is contained in:
Eason Goodale
2025-08-08 15:33:58 -07:00
parent 3ba4ac8ee6
commit 0ce885cb98
11 changed files with 1072 additions and 175 deletions
+15 -5
View File
@@ -5,23 +5,33 @@ 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;
use codex_login::logout;
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
pub async fn run_login_with_chatgpt(
cli_config_overrides: CliConfigOverrides,
no_browser: bool,
verbose: bool,
) -> ! {
let config = load_config_or_exit(cli_config_overrides);
let capture_output = false;
match login_with_chatgpt(&config.codex_home, capture_output).await {
let open_browser = !no_browser;
match login_with_chatgpt(&config.codex_home, open_browser, verbose).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);
}
}
}
}
+14 -1
View File
@@ -100,6 +100,14 @@ struct LoginCommand {
#[arg(long = "api-key", value_name = "API_KEY")]
api_key: Option<String>,
/// Do not automatically open the browser
#[arg(long = "no-browser")]
no_browser: bool,
/// Enable verbose request logging during login
#[arg(long = "verbose")]
verbose: bool,
#[command(subcommand)]
action: Option<LoginSubcommand>,
}
@@ -152,7 +160,12 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
if let Some(api_key) = login_cli.api_key {
run_login_with_api_key(login_cli.config_overrides, api_key).await;
} else {
run_login_with_chatgpt(login_cli.config_overrides).await;
run_login_with_chatgpt(
login_cli.config_overrides,
login_cli.no_browser,
login_cli.verbose,
)
.await;
}
}
}
-2
View File
@@ -43,5 +43,3 @@ pub(crate) fn write_auth_file(
f.write_all(contents.as_bytes())?;
f.flush()
}
-2
View File
@@ -19,5 +19,3 @@ fn decode_jwt_payload_segment(segment_b64: &str) -> serde_json::Value {
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
.unwrap_or(serde_json::Value::Object(Default::default()))
}
+52 -12
View File
@@ -7,8 +7,10 @@ use std::env;
use std::fs::File;
use std::fs::OpenOptions;
use std::fs::remove_file;
use std::io;
use std::io::Read;
use std::io::Write;
use std::net::TcpListener;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
@@ -23,21 +25,22 @@ use std::time::Duration;
pub use crate::token_data::TokenData;
use crate::token_data::parse_id_token;
mod token_data;
mod server;
mod pkce;
mod jwt_utils;
mod auth_file;
mod success_url;
mod jwt_utils;
mod pkce;
mod redeem;
pub use server::LoginServerOptions;
pub use server::run_local_login_server_with_options;
pub use server::process_callback_headless;
mod server;
mod success_url;
mod token_data;
pub use server::HeadlessOutcome;
pub use server::Http;
pub use server::LoginServerOptions;
pub use server::process_callback_headless;
pub use server::run_local_login_server_with_options;
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
pub const EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE: i32 = 13;
#[derive(Clone, Debug, PartialEq, Copy)]
pub enum AuthMode {
@@ -322,11 +325,48 @@ pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLog
/// If `capture_output` is true, the subprocess's output will be captured and
/// recorded in memory. Otherwise, the subprocess's output will be sent to the
/// current process's stdout/stderr.
pub async fn login_with_chatgpt(codex_home: &Path, _capture_output: bool) -> std::io::Result<()> {
pub async fn login_with_chatgpt(
codex_home: &Path,
open_browser: bool,
verbose: bool,
) -> std::io::Result<()> {
// Prefer env override to match Python flow expectations.
let client_id = std::env::var("CODEX_CLIENT_ID")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| CLIENT_ID.to_string());
// Mirror Python's special-case exit for address-in-use by pre-binding the port.
// Tiny race window is acceptable for UX parity.
match TcpListener::bind(("127.0.0.1", server::DEFAULT_PORT)) {
Ok(_sock) => {
// release immediately; server will bind next
}
Err(e) => {
if e.kind() == io::ErrorKind::AddrInUse {
return Err(io::Error::new(io::ErrorKind::AddrInUse, e));
}
}
}
let codex_home = codex_home.to_path_buf();
tokio::task::spawn_blocking(move || server::run_local_login_server(&codex_home, CLIENT_ID))
.await
.map_err(|e| std::io::Error::other(format!("task join error: {e}")))??;
let client_id_cloned = client_id.clone();
tokio::task::spawn_blocking(move || {
let opts = server::LoginServerOptions {
codex_home: codex_home.clone(),
client_id: client_id_cloned,
issuer: server::DEFAULT_ISSUER.to_string(),
port: server::DEFAULT_PORT,
open_browser,
redeem_credits: true,
expose_state_endpoint: false,
testing_timeout_secs: None,
verbose,
};
server::run_local_login_server_with_options(opts)
})
.await
.map_err(|e| std::io::Error::other(format!("task join error: {e}")))??;
Ok(())
}
+2 -3
View File
@@ -1,6 +1,7 @@
use base64::Engine as _;
use rand::RngCore;
use sha2::{Digest, Sha256};
use sha2::Digest;
use sha2::Sha256;
#[derive(Debug, Clone)]
pub(crate) struct PkceCodes {
@@ -19,5 +20,3 @@ pub(crate) fn generate_pkce() -> PkceCodes {
code_challenge,
}
}
+14 -9
View File
@@ -1,6 +1,8 @@
use crate::auth_file::now_rfc3339_z;
use crate::jwt_utils::parse_jwt_claims;
use chrono::{DateTime, Duration, Utc};
use chrono::DateTime;
use chrono::Duration;
use chrono::Utc;
use reqwest::blocking::Client;
use serde_json::json;
use std::time::Duration as StdDuration;
@@ -25,7 +27,7 @@ pub(crate) fn maybe_redeem_credits(
let mut token_expired = true;
if let Some(exp) = claims.get("exp").and_then(|v| v.as_i64()) {
let now_ms = (Utc::now().timestamp_millis()) as i64;
let now_ms = Utc::now().timestamp_millis();
token_expired = now_ms >= exp * 1000;
}
@@ -49,8 +51,13 @@ pub(crate) fn maybe_redeem_credits(
.json(&body)
.send();
let Ok(resp) = resp else { return };
let Ok(val) = resp.json::<serde_json::Value>() else { return };
let new_id_token = val.get("id_token").and_then(|v| v.as_str()).map(|s| s.to_string());
let Ok(val) = resp.json::<serde_json::Value>() else {
return;
};
let new_id_token = val
.get("id_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let new_refresh_token = val
.get("refresh_token")
.and_then(|v| v.as_str())
@@ -87,8 +94,8 @@ pub(crate) fn maybe_redeem_credits(
.get("chatgpt_subscription_active_start")
.and_then(|v| v.as_str())
{
if let Ok(sub_start) = DateTime::parse_from_rfc3339(sub_start_str)
.map(|dt| dt.with_timezone(&Utc))
if let Ok(sub_start) =
DateTime::parse_from_rfc3339(sub_start_str).map(|dt| dt.with_timezone(&Utc))
{
if Utc::now() - sub_start < Duration::days(7) {
eprintln!(
@@ -148,10 +155,8 @@ pub(crate) fn maybe_redeem_credits(
amount
);
} else {
eprintln!("It looks like no credits were granted: {}", val);
eprintln!("It looks like no credits were granted: {val}");
}
}
}
}
+369 -110
View File
@@ -5,11 +5,14 @@ use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use tiny_http::{Header, Method, Response, Server};
use tiny_http::Header;
use tiny_http::Method;
use tiny_http::Response;
use tiny_http::Server;
use url::Url;
use url::form_urlencoded;
use std::path::PathBuf;
use crate::auth_file::write_auth_file;
use crate::jwt_utils::parse_jwt_claims;
@@ -17,8 +20,8 @@ use crate::pkce::generate_pkce;
use crate::redeem::maybe_redeem_credits;
use crate::success_url::build_success_url;
const DEFAULT_PORT: u16 = 1455;
const DEFAULT_ISSUER: &str = "https://auth.openai.com";
pub const DEFAULT_PORT: u16 = 1455;
pub const DEFAULT_ISSUER: &str = "https://auth.openai.com";
// Copied from the Python HTML to keep UX consistent.
pub const LOGIN_SUCCESS_HTML: &str = include_str!("./success_page.html");
@@ -55,10 +58,14 @@ pub struct LoginServerOptions {
/// When set, the server will auto-exit after the specified number of seconds by
/// issuing an internal request to a test-only endpoint. Intended for CI/tests.
pub testing_timeout_secs: Option<u64>,
pub verbose: bool,
}
fn default_url_base(port: u16) -> String { format!("http://127.0.0.1:{port}") }
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 {
codex_home: codex_home.to_path_buf(),
@@ -69,17 +76,17 @@ pub fn run_local_login_server(codex_home: &Path, client_id: &str) -> std::io::Re
redeem_credits: true,
expose_state_endpoint: false,
testing_timeout_secs: None,
verbose: false,
};
run_local_login_server_with_options(opts)
}
pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io::Result<()> {
let addr = format!("127.0.0.1:{}", opts.port);
let server = Server::http(&addr)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
let server = Server::http(&addr).map_err(|e| std::io::Error::other(e.to_string()))?;
let issuer = opts.issuer.clone();
let token_endpoint = format!("{}/oauth/token", issuer);
let token_endpoint = format!("{issuer}/oauth/token");
let url_base = default_url_base(opts.port);
let pkce = generate_pkce();
@@ -89,9 +96,12 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
hex::encode(bytes)
};
let redirect_uri = format!("{}/auth/callback", url_base);
let mut auth_url = Url::parse(&format!("{}/oauth/authorize", issuer)).unwrap();
auth_url.query_pairs_mut()
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)
@@ -102,20 +112,19 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
.append_pair("codex_cli_simplified_flow", "true")
.append_pair("state", &state);
eprintln!("Starting local login server on {}", url_base);
eprintln!("Starting local login server on {url_base}");
// Try to open the browser, but ignore failures.
if opts.open_browser {
let _ = webbrowser::open(auth_url.as_str());
}
eprintln!(
". If your browser did not open, navigate to this URL to authenticate: \n\n{}",
auth_url
". If your browser did not open, navigate to this URL to authenticate: \n\n{auth_url}"
);
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
.map_err(|e| std::io::Error::other(e.to_string()))?;
// If a testing timeout is configured, schedule an internal exit request so tests don't hang.
if let Some(secs) = opts.testing_timeout_secs {
@@ -130,7 +139,9 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
'outer: loop {
let request = match server.recv() {
Ok(r) => r,
Err(e) => return Err(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())),
Err(e) => {
return Err(std::io::Error::other(e.to_string()));
}
};
// Parse URL path and query
@@ -140,11 +151,18 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
None => (full.clone(), None),
};
if opts.verbose {
eprintln!("{} {}", 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);
resp.add_header(Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap());
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);
}
let _ = request.respond(resp);
break 'outer;
}
@@ -155,28 +173,39 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
// Test-only helper to retrieve the current state, enabled via options.
(Method::Get, "/__test/state") if opts.expose_state_endpoint => {
let mut resp = Response::from_string(state.clone()).with_status_code(200);
resp.add_header(Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap());
if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]) {
resp.add_header(h);
}
let _ = request.respond(resp);
}
(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();
let params: HashMap<String, String> =
form_urlencoded::parse(query.as_deref().unwrap_or("").as_bytes())
.into_owned()
.collect();
if params.get("state").map(|s| s.as_str()) != Some(state.as_str()) {
let _ = request.respond(Response::from_string("State parameter mismatch").with_status_code(400));
let _ = request.respond(
Response::from_string("State parameter mismatch").with_status_code(400),
);
continue;
}
let code = match params.get("code").cloned() {
Some(c) if !c.is_empty() => c,
_ => {
let _ = request.respond(Response::from_string("Missing authorization code").with_status_code(400));
let _ = request.respond(
Response::from_string("Missing authorization code")
.with_status_code(400),
);
continue;
}
};
// 1) Authorization code -> tokens
if opts.verbose {
eprintln!("POST {token_endpoint} (authorization_code)");
}
let token_resp = client
.post(&token_endpoint)
.form(&[
@@ -188,12 +217,37 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
])
.send();
let Ok(token_resp) = token_resp else {
let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500));
if opts.verbose {
eprintln!("Token exchange failed: network error");
}
let _ = request.respond(
Response::from_string("Token exchange failed").with_status_code(500),
);
continue;
};
let Ok(tokens) = token_resp.json::<CodeExchangeResponse>() else {
let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500));
if !token_resp.status().is_success() {
let status = token_resp.status();
let body = token_resp.text().unwrap_or_default();
if opts.verbose {
eprintln!("Token exchange failed: status={status} body={body}");
}
let _ = request.respond(
Response::from_string("Token exchange failed").with_status_code(500),
);
continue;
}
let body_text = token_resp.text().unwrap_or_default();
let tokens: CodeExchangeResponse = match serde_json::from_str(&body_text) {
Ok(v) => v,
Err(e) => {
if opts.verbose {
eprintln!("Token exchange failed: invalid JSON: {e} body={body_text}");
}
let _ = request.respond(
Response::from_string("Token exchange failed").with_status_code(500),
);
continue;
}
};
// Extract account_id from id_token claims
@@ -213,61 +267,203 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
let org_id = access_auth_claims.get("organization_id").and_then(|v| v.as_str());
let project_id = access_auth_claims.get("project_id").and_then(|v| v.as_str());
let completed_onboarding = access_auth_claims
let id_auth_claims = id_claims
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
// Prefer ID-token claims (Python parity), fall back to access-token claims
let org_id = id_auth_claims
.get("organization_id")
.and_then(|v| v.as_str())
.or_else(|| {
access_auth_claims
.get("organization_id")
.and_then(|v| v.as_str())
});
let project_id = id_auth_claims
.get("project_id")
.and_then(|v| v.as_str())
.or_else(|| {
access_auth_claims
.get("project_id")
.and_then(|v| v.as_str())
});
let completed_onboarding = id_auth_claims
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
.or_else(|| {
access_auth_claims
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
})
.unwrap_or(false);
let is_org_owner = access_auth_claims
let is_org_owner = id_auth_claims
.get("is_org_owner")
.and_then(|v| v.as_bool())
.or_else(|| {
access_auth_claims
.get("is_org_owner")
.and_then(|v| v.as_bool())
})
.unwrap_or(false);
// Python uses access-token for plan type; match that
let plan_type = access_auth_claims
.get("chatgpt_plan_type")
.and_then(|v| v.as_str());
let needs_setup = !completed_onboarding && is_org_owner;
// 2) Token exchange for API key
// 2) Token exchange for API key (Python parity: only if org and project are present)
let today = Utc::now().format("%Y-%m-%d").to_string();
let random_id = {
let mut bytes = [0u8; 6];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
};
let token_x_resp = client
.post(&token_endpoint)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
("client_id", opts.client_id.as_str()),
("requested_token", "openai-api-key"),
("subject_token", tokens.id_token.as_str()),
("subject_token_type", "urn:ietf:params:oauth:token-type:id_token"),
(
"name",
format!("Codex CLI [auto-generated] ({today}) [{random_id}]").as_str(),
),
])
.send();
let Ok(token_x_resp) = token_x_resp else {
let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500));
continue;
};
let Ok(token_x) = token_x_resp.json::<TokenExchangeResponse>() else {
let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500));
continue;
let api_key_opt: Option<String> = if org_id.is_none() || project_id.is_none() {
if opts.verbose {
eprintln!(
"Skipping token exchange: missing org_id or project_id in token claims (Python parity)"
);
}
None
} else {
if opts.verbose {
eprintln!("POST {token_endpoint} (token-exchange, subject=id_token)");
}
let token_x_id = client
.post(&token_endpoint)
.form(&[
(
"grant_type",
"urn:ietf:params:oauth:grant-type:token-exchange",
),
("client_id", opts.client_id.as_str()),
("requested_token", "openai-api-key"),
("subject_token", tokens.id_token.as_str()),
(
"subject_token_type",
"urn:ietf:params:oauth:token-type:id_token",
),
(
"name",
format!("Codex CLI [auto-generated] ({today}) [{random_id}]")
.as_str(),
),
])
.send();
match token_x_id {
Ok(resp) if resp.status().is_success() => {
let body_text = resp.text().unwrap_or_default();
match serde_json::from_str::<TokenExchangeResponse>(&body_text) {
Ok(v) => Some(v.access_token),
Err(e) => {
if opts.verbose {
eprintln!(
"Token exchange failed: invalid JSON (id_token): {e} body={body_text}"
);
}
None
}
}
}
Ok(resp) => {
let status = resp.status();
let body = resp.text().unwrap_or_default();
if opts.verbose {
eprintln!(
"Token exchange failed (id_token): status={status} body={body}"
);
}
if status.as_u16() == 401 && body.contains("missing organization_id") {
if opts.verbose {
eprintln!("Retrying token exchange with access_token subject");
}
let retry = client
.post(&token_endpoint)
.form(&[
(
"grant_type",
"urn:ietf:params:oauth:grant-type:token-exchange",
),
("client_id", opts.client_id.as_str()),
("requested_token", "openai-api-key"),
("subject_token", tokens.access_token.as_str()),
(
"subject_token_type",
"urn:ietf:params:oauth:token-type:access_token",
),
(
"name",
format!(
"Codex CLI [auto-generated] ({today}) [{random_id}]"
)
.as_str(),
),
])
.send();
match retry {
Ok(retry_resp) if retry_resp.status().is_success() => {
let body_text = retry_resp.text().unwrap_or_default();
match serde_json::from_str::<TokenExchangeResponse>(
&body_text,
) {
Ok(v) => Some(v.access_token),
Err(e) => {
if opts.verbose {
eprintln!(
"Token exchange failed: invalid JSON (access_token): {e} body={body_text}"
);
}
None
}
}
}
Ok(retry_resp) => {
let status = retry_resp.status();
let body = retry_resp.text().unwrap_or_default();
if opts.verbose {
eprintln!(
"Token exchange failed (access_token): status={status} body={body}"
);
}
None
}
Err(_) => {
if opts.verbose {
eprintln!(
"Token exchange failed: network error (access_token)"
);
}
None
}
}
} else {
None
}
}
Err(_) => {
if opts.verbose {
eprintln!("Token exchange failed: network error (id_token)");
}
None
}
}
};
// Persist auth.json
if let Err(e) = write_auth_file(
&opts.codex_home,
Some(token_x.access_token.clone()),
api_key_opt.clone(),
&tokens.id_token,
&tokens.access_token,
&tokens.refresh_token,
account_id,
) {
let _ = request.respond(Response::from_string(format!("Unable to persist auth file: {e}")).with_status_code(500));
let _ = request.respond(
Response::from_string(format!("Unable to persist auth file: {e}"))
.with_status_code(500),
);
continue;
}
@@ -296,14 +492,19 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
plan_type,
needs_setup,
platform_url,
);
)
.map_err(|e| std::io::Error::other(e.to_string()))?;
let mut resp = Response::empty(302);
resp.add_header(Header::from_bytes(&b"Location"[..], success_url.as_str()).unwrap());
let location_value = success_url.to_string();
if let Ok(h) = Header::from_bytes(&b"Location"[..], location_value.as_str()) {
resp.add_header(h);
}
let _ = request.respond(resp);
}
_ => {
let _ = request.respond(Response::from_string("Endpoint not supported").with_status_code(404));
let _ = request
.respond(Response::from_string("Endpoint not supported").with_status_code(404));
}
}
}
@@ -320,32 +521,37 @@ pub struct HeadlessOutcome {
}
pub trait Http {
fn post_form(&self, url: &str, form: &[(String, String)]) -> std::io::Result<serde_json::Value>;
fn post_form(&self, url: &str, form: &[(String, String)])
-> std::io::Result<serde_json::Value>;
fn post_json(&self, url: &str, body: &serde_json::Value) -> std::io::Result<serde_json::Value>;
}
pub struct DefaultHttp(Client);
impl Default for DefaultHttp {
fn default() -> Self {
let c = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
.unwrap();
Self(c)
Self(Client::new())
}
}
impl Http for DefaultHttp {
fn post_form(&self, url: &str, form: &[(String, String)]) -> std::io::Result<serde_json::Value> {
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<_>>())
.form(
&form
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect::<Vec<_>>(),
)
.send()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
.map_err(|e| std::io::Error::other(e.to_string()))?;
let val = resp
.json::<serde_json::Value>()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
.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> {
@@ -354,10 +560,10 @@ impl Http for DefaultHttp {
.post(url)
.json(body)
.send()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
.map_err(|e| std::io::Error::other(e.to_string()))?;
let val = resp
.json::<serde_json::Value>()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
.map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(val)
}
}
@@ -377,7 +583,10 @@ pub fn process_callback_headless(
));
}
let code = code_opt.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing authorization code")
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"missing authorization code",
)
})?;
let token_endpoint = format!("{}/oauth/token", opts.issuer);
@@ -393,20 +602,26 @@ pub fn process_callback_headless(
];
let tokens_val = http.post_form(&token_endpoint, &form)?;
let id_token = tokens_val["id_token"].as_str().unwrap_or("").to_string();
let access_token = tokens_val["access_token"].as_str().unwrap_or("").to_string();
let refresh_token = tokens_val["refresh_token"].as_str().unwrap_or("").to_string();
let access_token = tokens_val["access_token"]
.as_str()
.unwrap_or("")
.to_string();
let refresh_token = tokens_val["refresh_token"]
.as_str()
.unwrap_or("")
.to_string();
if id_token.is_empty() || access_token.is_empty() || refresh_token.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"token exchange failed",
));
return Err(std::io::Error::other("token exchange failed"));
}
// Extract claims
// Extract claims with Python-parity precedence: prefer ID-token claims; fall back to access-token
let id_claims = parse_jwt_claims(&id_token);
let account_id = id_claims
let id_auth = id_claims
.get("https://api.openai.com/auth")
.and_then(|v| v.get("chatgpt_account_id"))
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
let account_id = id_auth
.get("chatgpt_account_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let access_claims = parse_jwt_claims(&access_token);
@@ -414,48 +629,88 @@ pub fn process_callback_headless(
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
let org_id = access_auth.get("organization_id").and_then(|v| v.as_str());
let project_id = access_auth.get("project_id").and_then(|v| v.as_str());
let completed = access_auth
let org_id = id_auth
.get("organization_id")
.and_then(|v| v.as_str())
.or_else(|| access_auth.get("organization_id").and_then(|v| v.as_str()));
let project_id = id_auth
.get("project_id")
.and_then(|v| v.as_str())
.or_else(|| access_auth.get("project_id").and_then(|v| v.as_str()));
let completed = id_auth
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
.or_else(|| {
access_auth
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
})
.unwrap_or(false);
let is_owner = access_auth
let is_owner = id_auth
.get("is_org_owner")
.and_then(|v| v.as_bool())
.or_else(|| access_auth.get("is_org_owner").and_then(|v| v.as_bool()))
.unwrap_or(false);
// Keep plan type from access token (Python behavior)
let plan_type = access_auth
.get("chatgpt_plan_type")
.and_then(|v| v.as_str())
.unwrap_or("");
let needs_setup = !completed && is_owner;
// 2) Token exchange -> API key
// 2) Token exchange -> API key (Python parity: only if org and project are present)
let today = Utc::now().format("%Y-%m-%d").to_string();
let random_id = {
let mut bytes = [0u8; 6];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
};
let exchange_form = vec![
(
"grant_type".to_string(),
"urn:ietf:params:oauth:grant-type:token-exchange".to_string(),
),
("client_id".to_string(), opts.client_id.clone()),
("requested_token".to_string(), "openai-api-key".to_string()),
("subject_token".to_string(), id_token.clone()),
(
"subject_token_type".to_string(),
"urn:ietf:params:oauth:token-type:id_token".to_string(),
),
(
"name".to_string(),
format!("Codex CLI [auto-generated] ({today}) [{random_id}]").to_string(),
),
];
let exchange_val = http.post_form(&token_endpoint, &exchange_form)?;
let api_key = exchange_val["access_token"].as_str().map(|s| s.to_string());
let api_key = if org_id.is_none() || project_id.is_none() {
None
} else {
let exchange_form = vec![
(
"grant_type".to_string(),
"urn:ietf:params:oauth:grant-type:token-exchange".to_string(),
),
("client_id".to_string(), opts.client_id.clone()),
("requested_token".to_string(), "openai-api-key".to_string()),
("subject_token".to_string(), id_token.clone()),
(
"subject_token_type".to_string(),
"urn:ietf:params:oauth:token-type:id_token".to_string(),
),
(
"name".to_string(),
format!("Codex CLI [auto-generated] ({today}) [{random_id}]"),
),
];
let mut exchange_val = http.post_form(&token_endpoint, &exchange_form)?;
let mut api_key = exchange_val["access_token"].as_str().map(|s| s.to_string());
if api_key.is_none() {
// Fallback: retry with access_token as subject
let exchange_form2 = vec![
(
"grant_type".to_string(),
"urn:ietf:params:oauth:grant-type:token-exchange".to_string(),
),
("client_id".to_string(), opts.client_id.clone()),
("requested_token".to_string(), "openai-api-key".to_string()),
("subject_token".to_string(), access_token.clone()),
(
"subject_token_type".to_string(),
"urn:ietf:params:oauth:token-type:access_token".to_string(),
),
(
"name".to_string(),
format!("Codex CLI [auto-generated] ({today}) [{random_id}]"),
),
];
exchange_val = http.post_form(&token_endpoint, &exchange_form2)?;
api_key = exchange_val["access_token"].as_str().map(|s| s.to_string());
}
api_key
};
// Persist auth.json
write_auth_file(
@@ -490,16 +745,20 @@ pub fn process_callback_headless(
Some(&id_token),
org_id,
project_id,
if plan_type.is_empty() { None } else { Some(plan_type) },
if plan_type.is_empty() {
None
} else {
Some(plan_type)
},
needs_setup,
platform_url,
);
)
.map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(HeadlessOutcome {
success_url: success_url.into_string(),
success_url: success_url.to_string(),
api_key,
})
}
// Success URL builder is in crate::success_url
+6 -6
View File
@@ -8,8 +8,8 @@ pub(crate) fn build_success_url(
plan_type: Option<&str>,
needs_setup: bool,
platform_url: &str,
) -> Url {
let mut success_url = Url::parse(&format!("{}/success", url_base)).expect("valid base url");
) -> 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);
}
@@ -17,7 +17,9 @@ pub(crate) fn build_success_url(
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);
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);
@@ -26,7 +28,5 @@ pub(crate) fn build_success_url(
.query_pairs_mut()
.append_pair("needs_setup", if needs_setup { "true" } else { "false" })
.append_pair("platform_url", platform_url);
success_url
Ok(success_url)
}
+47 -11
View File
@@ -1,12 +1,16 @@
use codex_login::{process_callback_headless, HeadlessOutcome, LoginServerOptions};
#![expect(clippy::unwrap_used)]
use codex_login::LoginServerOptions;
use codex_login::process_callback_headless;
use serde_json::json;
use std::cell::RefCell;
use std::collections::VecDeque;
use tempfile::TempDir;
type FormCapture = (String, Vec<(String, String)>);
#[derive(Default)]
struct MockHttp {
forms: RefCell<Vec<(String, Vec<(String, String)>)>>,
forms: RefCell<Vec<FormCapture>>,
jsons: RefCell<Vec<(String, serde_json::Value)>>,
replies: RefCell<VecDeque<serde_json::Value>>,
}
@@ -29,21 +33,17 @@ impl codex_login::Http for MockHttp {
self.replies
.borrow_mut()
.pop_front()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "no reply"))
.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> {
fn post_json(&self, url: &str, body: &serde_json::Value) -> std::io::Result<serde_json::Value> {
self.jsons
.borrow_mut()
.push((url.to_string(), body.clone()));
self.replies
.borrow_mut()
.pop_front()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "no reply"))
.ok_or_else(|| std::io::Error::other("no reply"))
}
}
@@ -67,6 +67,7 @@ fn default_opts(tmp: &TempDir) -> LoginServerOptions {
redeem_credits: true,
expose_state_endpoint: false,
testing_timeout_secs: None,
verbose: false,
}
}
@@ -87,7 +88,8 @@ fn headless_success_writes_auth_and_url() {
// Credits redeem
http.queue(json!({"granted_chatgpt_subscriber_api_credits": 5}));
let outcome = process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
let outcome =
process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
assert!(outcome.success_url.contains("/success"));
let contents = std::fs::read_to_string(tmp.path().join("auth.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&contents).unwrap();
@@ -147,9 +149,43 @@ fn headless_credit_redemption_best_effort() {
// Token exchange -> API key
http.queue(json!({"access_token": "sk-xyz"}));
// Credits redeem: simulate error by not queuing a third response; the mock will error internally
let outcome = process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
let outcome =
process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
assert!(outcome.success_url.contains("needs_setup=true"));
assert!(tmp.path().join("auth.json").exists());
}
// 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"
}));
// Token exchange -> API key
http.queue(json!({"access_token": "sk-xyz"}));
// Credits redeem
http.queue(json!({"granted_chatgpt_subscriber_api_credits": 0}));
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"));
}
+553 -14
View File
@@ -1,6 +1,7 @@
#![cfg(feature = "http-e2e-tests")]
use codex_login::{run_local_login_server_with_options, LoginServerOptions};
use base64::Engine;
use codex_login::LoginServerOptions;
use codex_login::run_local_login_server_with_options;
use std::io::Read;
use std::net::TcpListener;
// use std::path::PathBuf;
@@ -9,7 +10,11 @@ use std::time::Duration;
use tempfile::TempDir;
fn find_free_port() -> u16 {
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
fn start_mock_oauth_server(port: u16, behavior: MockBehavior) {
@@ -49,7 +54,8 @@ fn start_mock_oauth_server(port: u16, behavior: MockBehavior) {
match behavior {
MockBehavior::Success => {
if form.get("grant_type").map(|s| s.as_str()) == Some("authorization_code") {
if form.get("grant_type").map(|s| s.as_str()) == Some("authorization_code")
{
// Return tokens
let id_token = make_fake_jwt(serde_json::json!({
"https://api.openai.com/auth": {
@@ -74,7 +80,11 @@ fn start_mock_oauth_server(port: u16, behavior: MockBehavior) {
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
} else {
@@ -86,13 +96,315 @@ fn start_mock_oauth_server(port: u16, behavior: MockBehavior) {
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
}
}
MockBehavior::SuccessNeedsSetup => {
if form.get("grant_type").map(|s| s.as_str()) == Some("authorization_code")
{
// Return tokens with needs_setup=true conditions
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"
});
let _ = request.respond(
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
} else {
let payload = serde_json::json!({
"access_token": "sk-test-123"
});
let _ = request.respond(
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
}
}
MockBehavior::SuccessIdClaimsOrgProject => {
if form.get("grant_type").map(|s| s.as_str()) == Some("authorization_code")
{
// Put org/project and flags only in ID token; access holds plan_type
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"
});
let _ = request.respond(
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
} else {
let payload = serde_json::json!({
"access_token": "sk-test-123"
});
let _ = request.respond(
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
}
}
MockBehavior::TokenError => {
let _ = request.respond(tiny_http::Response::from_string("error").with_status_code(500));
let _ = request.respond(
tiny_http::Response::from_string("error").with_status_code(500),
);
}
MockBehavior::MissingOrgSkipExchange => {
if form.get("grant_type").map(|s| s.as_str()) == Some("authorization_code")
{
// Return tokens with no org/project in either token
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"
});
let _ = request.respond(
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
} else {
// Should not be called in this behavior; return error if it is
let _ = request.respond(
tiny_http::Response::from_string("unexpected token-exchange")
.with_status_code(500),
);
}
}
MockBehavior::IdMissingOrgThenAccessSucceeds => {
if form.get("grant_type").map(|s| s.as_str()) == Some("authorization_code")
{
// Include org/project in access claims so server attempts exchange
let id_token = make_fake_jwt(serde_json::json!({
"https://api.openai.com/auth": {
"chatgpt_account_id": "acc-5"
}
}));
let access_token = make_fake_jwt(serde_json::json!({
"https://api.openai.com/auth": {
"organization_id": "org-x",
"project_id": "proj-x",
"completed_platform_onboarding": true,
"is_org_owner": false,
"chatgpt_plan_type": "plus"
}
}));
let payload = serde_json::json!({
"id_token": id_token,
"access_token": access_token,
"refresh_token": "refresh-5"
});
let _ = request.respond(
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
} else {
// Distinguish by subject_token_type
match form.get("subject_token_type").map(|s| s.as_str()) {
Some("urn:ietf:params:oauth:token-type:id_token") => {
let body = serde_json::json!({
"error": {
"message": "Invalid ID token: missing organization_id",
"type": "invalid_request_error",
"param": null,
"code": "invalid_subject_token"
}
});
let _ = request.respond(
tiny_http::Response::from_string(body.to_string())
.with_status_code(401)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
}
Some("urn:ietf:params:oauth:token-type:access_token") => {
let body =
serde_json::json!({"access_token": "sk-fallback-xyz"});
let _ = request.respond(
tiny_http::Response::from_string(body.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
}
_ => {
let _ = request.respond(
tiny_http::Response::from_string("bad subject")
.with_status_code(400),
);
}
}
}
}
MockBehavior::IdMissingOrgThenAccessFails => {
if form.get("grant_type").map(|s| s.as_str()) == Some("authorization_code")
{
let id_token = make_fake_jwt(serde_json::json!({
"https://api.openai.com/auth": {"chatgpt_account_id": "acc-6"}
}));
let access_token = make_fake_jwt(serde_json::json!({
"https://api.openai.com/auth": {
"organization_id": "org-y",
"project_id": "proj-y",
"completed_platform_onboarding": true,
"is_org_owner": false,
"chatgpt_plan_type": "plus"
}
}));
let payload = serde_json::json!({
"id_token": id_token,
"access_token": access_token,
"refresh_token": "refresh-6"
});
let _ = request.respond(
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
} else {
match form.get("subject_token_type").map(|s| s.as_str()) {
Some("urn:ietf:params:oauth:token-type:id_token") => {
let body = serde_json::json!({
"error": {"message": "Invalid ID token: missing organization_id", "type": "invalid_request_error", "param": null, "code": "invalid_subject_token"}
});
let _ = request.respond(
tiny_http::Response::from_string(body.to_string())
.with_status_code(401)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
}
Some("urn:ietf:params:oauth:token-type:access_token") => {
let body = serde_json::json!({
"error": {"message": "Could not validate your subject token. Please try signing in again.", "type": "invalid_request_error", "param": null, "code": "invalid_subject_token"}
});
let _ = request.respond(
tiny_http::Response::from_string(body.to_string())
.with_status_code(401)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
}
_ => {
let _ = request.respond(
tiny_http::Response::from_string("bad subject")
.with_status_code(400),
);
}
}
}
}
}
} else if request.method() == &tiny_http::Method::Post
@@ -103,11 +415,16 @@ fn start_mock_oauth_server(port: u16, behavior: MockBehavior) {
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
} else {
let _ = request.respond(tiny_http::Response::from_string("not found").with_status_code(404));
let _ = request
.respond(tiny_http::Response::from_string("not found").with_status_code(404));
}
}
});
@@ -116,7 +433,12 @@ fn start_mock_oauth_server(port: u16, behavior: MockBehavior) {
#[derive(Clone, Copy)]
enum MockBehavior {
Success,
SuccessNeedsSetup,
SuccessIdClaimsOrgProject,
TokenError,
MissingOrgSkipExchange,
IdMissingOrgThenAccessSucceeds,
IdMissingOrgThenAccessFails,
}
fn make_fake_jwt(payload: serde_json::Value) -> String {
@@ -129,10 +451,11 @@ fn make_fake_jwt(payload: serde_json::Value) -> String {
}
fn http_get(url: &str) -> (u16, String, Option<String>) {
let resp = ureq::get(url).call().expect("http get failed");
let agent = ureq::AgentBuilder::new().redirects(0).build();
let resp = agent.get(url).call().expect("http get failed");
let status = resp.status();
let location = resp.header("Location").map(|s| s.to_string());
let body = resp.into_string().unwrap_or_default();
let location = None; // ureq consumed headers in previous call; not needed here
(status as u16, body, location)
}
@@ -164,6 +487,7 @@ fn login_server_happy_path() {
redeem_credits: true,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
@@ -178,6 +502,16 @@ fn login_server_happy_path() {
// 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
let (status, body) = http_get_follow_redirect(&cb_url);
assert_eq!(status, 200);
assert!(body.contains("Signed in to Codex CLI"));
@@ -191,7 +525,197 @@ fn login_server_happy_path() {
assert_eq!(v["OPENAI_API_KEY"].as_str(), Some("sk-test-123"));
assert!(v["tokens"]["id_token"].as_str().is_some());
}
// 1b) needs_setup=true when onboarding incomplete and is_org_owner=true
#[test]
fn login_server_needs_setup_true_and_params_present() {
let oauth_port = find_free_port();
start_mock_oauth_server(oauth_port, MockBehavior::SuccessNeedsSetup);
let codex_home = TempDir::new().unwrap();
let port = find_free_port();
let issuer = format!("http://127.0.0.1:{oauth_port}");
let opts = LoginServerOptions {
codex_home: codex_home.path().to_path_buf(),
client_id: "test-client".to_string(),
issuer: issuer.clone(),
port,
open_browser: false,
redeem_credits: true,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
wait_for_state_endpoint(port, Duration::from_secs(5));
let state_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();
}
// 1c) org/project from ID token only should appear in redirect (fallback logic)
#[test]
fn login_server_id_token_fallback_for_org_and_project() {
let oauth_port = find_free_port();
start_mock_oauth_server(oauth_port, MockBehavior::SuccessIdClaimsOrgProject);
let codex_home = TempDir::new().unwrap();
let port = find_free_port();
let issuer = format!("http://127.0.0.1:{oauth_port}");
let opts = LoginServerOptions {
codex_home: codex_home.path().to_path_buf(),
client_id: "test-client".to_string(),
issuer: issuer.clone(),
port,
open_browser: false,
redeem_credits: true,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
wait_for_state_endpoint(port, Duration::from_secs(5));
let state_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();
}
// 1d) Missing org/project in claims -> skip token-exchange, persist tokens without API key, still success
#[test]
fn login_server_skips_exchange_when_no_org_or_project() {
let oauth_port = find_free_port();
start_mock_oauth_server(oauth_port, MockBehavior::MissingOrgSkipExchange);
let codex_home = TempDir::new().unwrap();
let port = find_free_port();
let issuer = format!("http://127.0.0.1:{oauth_port}");
let opts = LoginServerOptions {
codex_home: codex_home.path().to_path_buf(),
client_id: "test-client".to_string(),
issuer: issuer.clone(),
port,
open_browser: false,
redeem_credits: true,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
wait_for_state_endpoint(port, Duration::from_secs(5));
let state_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();
// Verify auth.json OPENAI_API_KEY is null
let auth_path = codex_home.path().join("auth.json");
let contents = std::fs::read_to_string(&auth_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert!(v["OPENAI_API_KEY"].is_null());
}
// 1e) Exchange: ID-token missing org -> retry access-token succeeds
#[test]
fn login_server_exchange_fallback_to_access_token() {
let oauth_port = find_free_port();
start_mock_oauth_server(oauth_port, MockBehavior::IdMissingOrgThenAccessSucceeds);
let codex_home = TempDir::new().unwrap();
let port = find_free_port();
let issuer = format!("http://127.0.0.1:{oauth_port}");
let opts = LoginServerOptions {
codex_home: codex_home.path().to_path_buf(),
client_id: "test-client".to_string(),
issuer: issuer.clone(),
port,
open_browser: false,
redeem_credits: true,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
wait_for_state_endpoint(port, Duration::from_secs(5));
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
.call()
.unwrap()
.into_string()
.unwrap();
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
let (status, _body, _loc) = http_get(&cb_url);
assert_eq!(status, 302);
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
handle.join().unwrap();
let auth_path = codex_home.path().join("auth.json");
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&auth_path).unwrap()).unwrap();
assert_eq!(v["OPENAI_API_KEY"].as_str(), Some("sk-fallback-xyz"));
}
// 1f) Exchange: ID-token missing org -> retry access-token fails; still success and tokens persisted w/o API key
#[test]
fn login_server_exchange_fallback_fails_persists_tokens() {
let oauth_port = find_free_port();
start_mock_oauth_server(oauth_port, MockBehavior::IdMissingOrgThenAccessFails);
let codex_home = TempDir::new().unwrap();
let port = find_free_port();
let issuer = format!("http://127.0.0.1:{oauth_port}");
let opts = LoginServerOptions {
codex_home: codex_home.path().to_path_buf(),
client_id: "test-client".to_string(),
issuer: issuer.clone(),
port,
open_browser: false,
redeem_credits: true,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
wait_for_state_endpoint(port, Duration::from_secs(5));
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
.call()
.unwrap()
.into_string()
.unwrap();
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
let (status, _body, _loc) = http_get(&cb_url);
assert_eq!(status, 302);
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
handle.join().unwrap();
let auth_path = codex_home.path().join("auth.json");
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&auth_path).unwrap()).unwrap();
assert!(v["OPENAI_API_KEY"].is_null());
}
// 2) State mismatch returns 400 and server stays up
#[test]
fn login_server_state_mismatch() {
@@ -210,6 +734,7 @@ fn login_server_state_mismatch() {
redeem_credits: false,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
wait_for_state_endpoint(port, Duration::from_secs(5));
@@ -241,12 +766,17 @@ fn login_server_missing_code() {
redeem_credits: false,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
wait_for_state_endpoint(port, Duration::from_secs(5));
// Fetch state
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call().expect("get state").into_string().unwrap();
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
.call()
.expect("get state")
.into_string()
.unwrap();
// Missing code
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?state={state}");
let (status, _body) = http_get_follow_redirect(&cb_url);
@@ -272,10 +802,15 @@ fn login_server_token_exchange_error() {
redeem_credits: false,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
wait_for_state_endpoint(port, Duration::from_secs(5));
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call().expect("get state").into_string().unwrap();
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
.call()
.expect("get state")
.into_string()
.unwrap();
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
let (status, _body) = http_get_follow_redirect(&cb_url);
assert_eq!(status, 500);
@@ -301,10 +836,15 @@ fn login_server_credit_redemption_best_effort() {
redeem_credits: true,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
wait_for_state_endpoint(port, Duration::from_secs(5));
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call().expect("get state").into_string().unwrap();
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);
@@ -313,7 +853,6 @@ fn login_server_credit_redemption_best_effort() {
assert!(codex_home.path().join("auth.json").exists());
}
fn wait_for_state_endpoint(port: u16, timeout: Duration) {
let start = std::time::Instant::now();
loop {