mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
works, fmt, and clippy
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,5 +43,3 @@ pub(crate) fn write_auth_file(
|
||||
f.write_all(contents.as_bytes())?;
|
||||
f.flush()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user