feedback, error page

This commit is contained in:
Eason Goodale
2025-08-11 18:12:38 -07:00
parent 47dcb4377c
commit 90db5317d7
13 changed files with 265 additions and 120 deletions

17
codex-rs/Cargo.lock generated
View File

@@ -814,6 +814,7 @@ dependencies = [
"base64 0.22.1",
"chrono",
"hex",
"html-escape",
"pretty_assertions",
"rand 0.8.5",
"reqwest",
@@ -824,6 +825,7 @@ dependencies = [
"thiserror 2.0.12",
"tiny_http",
"tokio",
"tracing",
"ureq",
"url",
"urlencoding",
@@ -1981,6 +1983,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "html-escape"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
dependencies = [
"utf8-width",
]
[[package]]
name = "http"
version = "1.3.1"
@@ -5295,6 +5306,12 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8-width"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
[[package]]
name = "utf8_iter"
version = "1.0.4"

View File

@@ -13,13 +13,10 @@ use codex_login::logout;
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 open_browser = !no_browser;
match login_with_chatgpt(&config.codex_home, open_browser, verbose).await {
match login_with_chatgpt(&config.codex_home).await {
Ok(_) => {
eprintln!("Successfully logged in");
std::process::exit(0);

View File

@@ -100,14 +100,6 @@ 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>,
}
@@ -162,8 +154,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
} else {
run_login_with_chatgpt(
login_cli.config_overrides,
login_cli.no_browser,
login_cli.verbose,
)
.await;
}

View File

@@ -31,6 +31,8 @@ tiny_http = "0.12"
url = "2"
urlencoding = "2"
webbrowser = "1"
tracing = "0.1"
html-escape = "0.2"
[dev-dependencies]
pretty_assertions = "1.4.1"

View File

@@ -38,8 +38,8 @@ pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
}
}
/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory.
/// Returns the full AuthDotJson structure after refreshing if necessary.
/// Attempt to read and deserialize the `auth.json` file at the given path.
/// Returns the full AuthDotJson structure.
pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result<AuthDotJson> {
let mut file = File::open(auth_file)?;
let mut contents = String::new();

View File

@@ -81,8 +81,6 @@ pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLog
/// Entrypoint used by the CLI to run the local login server.
pub async fn login_with_chatgpt(
codex_home: &Path,
open_browser: bool,
verbose: bool,
) -> std::io::Result<()> {
let client_id = std::env::var("CODEX_CLIENT_ID")
.ok()
@@ -97,11 +95,9 @@ pub async fn login_with_chatgpt(
client_id: client_id_cloned,
issuer: server::DEFAULT_ISSUER.to_string(),
port: server::DEFAULT_PORT,
open_browser,
redeem_credits: true,
open_browser: true,
expose_state_endpoint: false,
testing_timeout_secs: None,
verbose,
#[cfg(feature = "http-e2e-tests")]
port_sender: None,
};

View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sign-in error · Codex CLI</title>
<link rel="icon" href='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E' type="image/svg+xml">
<style>
.container {
margin: auto;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: white;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.inner-container {
width: 520px;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: inline-flex;
}
.content {
align-self: stretch;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: flex;
margin-top: 15vh;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
border-radius: 16px;
border: .5px solid rgba(0, 0, 0, 0.1);
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 16px 0px;
box-sizing: border-box;
background-color: rgb(255, 255, 255);
}
.title {
text-align: center;
color: var(--text-primary, #0D0D0D);
font-size: 28px;
font-weight: 500;
line-height: 36px;
word-wrap: break-word;
}
.box {
width: 600px;
padding: 16px 20px;
background: var(--bg-primary, white);
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.05);
border-radius: 16px;
outline: 1px var(--border-default, rgba(13, 13, 13, 0.10)) solid;
outline-offset: -1px;
justify-content: flex-start;
align-items: center;
gap: 16px;
display: inline-flex;
}
.message {
align-self: stretch;
color: var(--text-secondary, #5D5D5D);
font-size: 14px;
line-height: 20px;
}
.help {
align-self: stretch;
color: var(--text-secondary, #5D5D5D);
font-size: 13px;
line-height: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="inner-container">
<div class="content">
<div class="logo">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path stroke="#000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"></path></svg>
</div>
<div class="title">We couldn't complete sign-in</div>
</div>
<div class="box">
<div class="message" id="message">%%MESSAGE%%</div>
</div>
<div class="help">Try restarting the sign-in from the Codex CLI. You can also copy the URL from the terminal and open it manually.</div>
</div>
</div>
</body>
</html>

View File

@@ -19,14 +19,7 @@ use tempfile::tempdir;
const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z";
#[test]
fn writes_api_key_and_loads_auth() {
let dir = tempdir().unwrap();
crate::auth_store::login_with_api_key(dir.path(), "sk-test-key").unwrap();
let auth = load_auth(dir.path(), false).unwrap().unwrap();
assert_eq!(auth.mode, AuthMode::ApiKey);
assert_eq!(auth.api_key.as_deref(), Some("sk-test-key"));
}
// moved to integration tests in tests/api_key_login.rs
#[test]
fn loads_from_env_var_if_env_var_exists() {
@@ -254,3 +247,39 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> {
assert!(!dir.path().join("auth.json").exists());
Ok(())
}
#[test]
fn update_tokens_preserves_id_token_as_string() {
let dir = tempdir().unwrap();
let auth_file = crate::auth_store::get_auth_file(dir.path());
// Write an initial auth.json with a tokens object
let initial = serde_json::json!({
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "old-id-token",
"access_token": "a1",
"refresh_token": "r1"
},
"last_refresh": LAST_REFRESH
});
std::fs::write(&auth_file, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
// Build a valid-looking JWT (URL-safe base64 header.payload.signature)
#[derive(Serialize)]
struct Header { alg: &'static str, typ: &'static str }
let header = Header { alg: "none", typ: "JWT" };
let payload = serde_json::json!({});
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
let header_b64 = b64(&serde_json::to_vec(&header).unwrap());
let payload_b64 = b64(&serde_json::to_vec(&payload).unwrap());
let signature_b64 = b64(b"sig");
let new_id = format!("{header_b64}.{payload_b64}.{signature_b64}");
// Call update_tokens with a new id_token
let _ = crate::auth_store::update_tokens(&auth_file, new_id.clone(), None, None).unwrap();
// Read raw file and ensure id_token is still a string, equal to what we wrote
let raw = std::fs::read_to_string(&auth_file).unwrap();
let val: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(val["tokens"]["id_token"].as_str(), Some(new_id.as_str()));
}

View File

@@ -1,7 +1,6 @@
//
use rand::RngCore;
use reqwest::blocking::Client;
use serde_json::json;
use std::collections::HashMap;
use std::net::TcpListener;
use std::path::Path;
@@ -19,11 +18,20 @@ use crate::auth_store::write_new_auth_json;
use crate::pkce::generate_pkce;
use crate::success_url::build_success_url;
use crate::token_data::extract_login_context_from_tokens;
use tracing::{error, trace};
pub const DEFAULT_PORT: u16 = 1455;
pub const DEFAULT_ISSUER: &str = "https://auth.openai.com";
pub const LOGIN_SUCCESS_HTML: &str = include_str!("./success_page.html");
pub const LOGIN_ERROR_HTML: &str = include_str!("./error_page.html");
fn render_error_html(message: &str) -> String {
LOGIN_ERROR_HTML.replace(
"%%MESSAGE%%",
&html_escape::encode_text(message).to_string(),
)
}
#[derive(Debug, Clone)]
pub struct LoginServerOptions {
@@ -32,18 +40,15 @@ pub struct LoginServerOptions {
pub issuer: String,
pub port: u16,
pub open_browser: bool,
pub redeem_credits: bool,
pub expose_state_endpoint: bool,
/// timeout after x secs for e2e tests
pub testing_timeout_secs: Option<u64>,
pub verbose: bool,
#[cfg(feature = "http-e2e-tests")]
pub port_sender: Option<std::sync::mpsc::Sender<u16>>,
}
// Only default issuer supported for platform/api bases
const PLATFORM_BASE: &str = "https://platform.openai.com";
const API_BASE: &str = "https://api.openai.com";
fn default_url_base(port: u16) -> String {
format!("http://localhost:{port}")
@@ -57,10 +62,8 @@ pub fn run_local_login_server(codex_home: &Path, client_id: &str) -> std::io::Re
issuer: DEFAULT_ISSUER.to_string(),
port: DEFAULT_PORT,
open_browser: true,
redeem_credits: true,
expose_state_endpoint: false,
testing_timeout_secs: None,
verbose: false,
#[cfg(feature = "http-e2e-tests")]
port_sender: None,
};
@@ -143,9 +146,7 @@ pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std:
None => (full.clone(), None),
};
if opts.verbose {
eprintln!("{} {}", request.method().as_str(), request.url());
}
trace!("{} {}", request.method().as_str(), request.url());
match (request.method().clone(), path.as_str()) {
(Method::Get, "/success") => {
@@ -155,11 +156,16 @@ pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std:
{
resp.add_header(h);
}
let _ = request.respond(resp);
if let Err(e) = request.respond(resp) {
error!("failed to respond to /success: {e}");
}
break 'outer;
}
(Method::Get, "/__test/exit") => {
let _ = request.respond(Response::from_string("bye").with_status_code(200));
if let Err(e) = request.respond(Response::from_string("bye").with_status_code(200))
{
error!("failed to respond to /__test/exit: {e}");
}
break 'outer;
}
// Test-only helper to retrieve the current state, enabled via options.
@@ -169,7 +175,9 @@ pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std:
if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]) {
resp.add_header(h);
}
let _ = request.respond(resp);
if let Err(e) = request.respond(resp) {
error!("failed to respond to /__test/state: {e}");
}
}
(Method::Get, "/auth/callback") => {
// Parse query params
@@ -180,16 +188,24 @@ pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std:
// Preserve explicit error messages for tests
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 mut resp = Response::from_string(render_error_html("State parameter mismatch")).with_status_code(400);
if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) {
resp.add_header(h);
}
if let Err(e) = request.respond(resp) {
error!("failed to respond to state mismatch: {e}");
}
continue;
}
let code_opt = params.get("code").map(|s| s.as_str());
if code_opt.map(|s| s.is_empty()).unwrap_or(true) {
let _ = request.respond(
Response::from_string("Missing authorization code").with_status_code(400),
);
let mut resp = Response::from_string(render_error_html("Missing authorization code")).with_status_code(400);
if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) {
resp.add_header(h);
}
if let Err(e) = request.respond(resp) {
error!("failed to respond to missing code: {e}");
}
continue;
}
@@ -210,18 +226,27 @@ pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std:
{
resp.add_header(h);
}
let _ = request.respond(resp);
if let Err(e) = request.respond(resp) {
error!("failed to respond redirect to success: {e}");
}
}
Err(_) => {
let _ = request.respond(
Response::from_string("Token exchange failed").with_status_code(500),
);
let mut resp = Response::from_string(render_error_html("Token exchange failed")).with_status_code(500);
if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) {
resp.add_header(h);
}
if let Err(e) = request.respond(resp) {
error!("failed to respond to token exchange failure: {e}");
}
}
}
}
_ => {
let _ = request
.respond(Response::from_string("Endpoint not supported").with_status_code(404));
if let Err(e) = request.respond(
Response::from_string("Endpoint not supported").with_status_code(404),
) {
error!("failed to respond 404: {e}");
}
}
}
}
@@ -348,10 +373,7 @@ pub fn process_callback_headless(
account_id,
)?;
if opts.redeem_credits {
let redeem_url = format!("{API_BASE}/v1/billing/redeem_credits");
let _ = http.post_json(&redeem_url, &json!({"id_token": id_token}));
}
// Intentionally not redeeming credits here
let base = default_url_base(opts.port);
let platform_url = PLATFORM_BASE;

View File

@@ -83,19 +83,7 @@ pub(crate) enum KnownPlan {
Edu,
}
#[derive(Deserialize)]
struct IdClaims {
#[serde(default)]
email: Option<String>,
#[serde(rename = "https://api.openai.com/auth", default)]
auth: Option<AuthClaims>,
}
#[derive(Deserialize)]
struct AuthClaims {
#[serde(default)]
chatgpt_plan_type: Option<PlanType>,
}
// Removed duplicate IdClaims/AuthClaims in favor of unified helpers below
#[derive(Debug, Error)]
pub enum IdTokenInfoError {
@@ -108,16 +96,10 @@ pub enum IdTokenInfoError {
}
pub(crate) fn parse_id_token(id_token: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
// JWT format: header.payload.signature
let mut parts = id_token.split('.');
let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
_ => return Err(IdTokenInfoError::InvalidFormat),
};
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?;
let claims: IdClaims = serde_json::from_slice(&payload_bytes)?;
// Reuse the generic JWT parsing helpers to extract fields
let payload = decode_jwt_payload(id_token).ok_or(IdTokenInfoError::InvalidFormat)?;
// Reuse AuthOuterClaims instead of a local struct to avoid duplication
let claims: AuthOuterClaims = serde_json::from_slice(&payload)?;
Ok(IdTokenInfo {
email: claims.email,
chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type),
@@ -136,6 +118,8 @@ where
#[derive(Default, Deserialize)]
struct AuthOuterClaims {
#[serde(default)]
email: Option<String>,
#[serde(rename = "https://api.openai.com/auth", default)]
auth: Option<AuthInnerClaims>,
}

View File

@@ -0,0 +1,12 @@
use tempfile::tempdir;
#[tokio::test]
async fn writes_api_key_and_loads_auth() {
let dir = tempdir().unwrap();
codex_login::login_with_api_key(dir.path(), "sk-test-key").unwrap();
let auth = codex_login::CodexAuth::from_codex_home(dir.path())
.unwrap()
.unwrap();
assert_eq!(auth.mode, codex_login::AuthMode::ApiKey);
assert_eq!(auth.get_token().await.unwrap().as_str(), "sk-test-key");
}

View File

@@ -57,10 +57,8 @@ fn default_opts(tmp: &TempDir) -> LoginServerOptions {
issuer: "http://auth.local".into(),
port: 1455,
open_browser: false,
redeem_credits: true,
expose_state_endpoint: false,
testing_timeout_secs: None,
verbose: false,
#[cfg(feature = "http-e2e-tests")]
port_sender: None,
}
@@ -78,8 +76,6 @@ fn headless_success_writes_auth_and_url() {
"access_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"organization_id": "org","project_id": "proj","completed_platform_onboarding": true, "is_org_owner": false, "chatgpt_plan_type": "plus"}})),
"refresh_token": "r1"
}));
// Credits redeem
http.queue(json!({"granted_chatgpt_subscriber_api_credits": 5}));
let outcome =
process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
@@ -126,25 +122,7 @@ fn headless_token_endpoint_failure() {
assert_eq!(err.kind(), std::io::ErrorKind::Other);
}
// 5) Credit redemption best-effort: even if it errors, success persists
#[test]
fn headless_credit_redemption_best_effort() {
let tmp = TempDir::new().unwrap();
let mut opts = default_opts(&tmp);
opts.redeem_credits = true;
let http = MockHttp::default();
// Code exchange
http.queue(json!({
"id_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"chatgpt_account_id": "acc"}})),
"access_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"organization_id": "org","project_id": "proj","completed_platform_onboarding": false, "is_org_owner": true, "chatgpt_plan_type": "pro"}})),
"refresh_token": "r1"
}));
// Credits redeem: simulate error by not queuing a third response; the mock will error internally
let outcome =
process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
assert!(outcome.success_url.contains("needs_setup=true"));
assert!(tmp.path().join("auth.json").exists());
}
// 5) (Removed) Credit redemption is no longer attempted
// 6) ID-token fallback for org/project/flags
#[test]
@@ -170,8 +148,6 @@ fn headless_id_token_fallback_for_org_and_project() {
})),
"refresh_token": "r1"
}));
// Credits redeem
http.queue(json!({"granted_chatgpt_subscriber_api_credits": 0}));
let outcome =
process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();

View File

@@ -198,7 +198,6 @@ use common::make_fake_jwt;
fn spawn_login_server_and_wait(
issuer: String,
codex_home: &tempfile::TempDir,
redeem_credits: bool,
) -> (std::thread::JoinHandle<std::io::Result<()>>, u16) {
let (tx, rx) = std::sync::mpsc::channel();
let opts = LoginServerOptions {
@@ -207,10 +206,8 @@ fn spawn_login_server_and_wait(
issuer,
port: 0,
open_browser: false,
redeem_credits,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
#[cfg(feature = "http-e2e-tests")]
port_sender: Some(tx),
};
@@ -239,6 +236,24 @@ fn http_get(url: &str) -> (u16, String, Option<String>) {
}
}
fn http_get_with_ct(url: &str) -> (u16, String, Option<String>) {
let agent = ureq::AgentBuilder::new().redirects(0).build();
match agent.get(url).call() {
Ok(resp) => {
let status = resp.status();
let content_type = resp.header("content-type").map(|s| s.to_string());
let body = resp.into_string().unwrap_or_default();
(status, body, content_type)
}
Err(ureq::Error::Status(code, resp)) => {
let content_type = resp.header("content-type").map(|s| s.to_string());
let body = resp.into_string().unwrap_or_default();
(code, body, content_type)
}
Err(err) => panic!("http error: {err}"),
}
}
fn http_get_follow_redirect(url: &str) -> (u16, String) {
let agent = ureq::AgentBuilder::new().redirects(5).build();
match agent.get(url).call() {
@@ -255,7 +270,7 @@ async fn login_server_happy_path() {
let codex_home = TempDir::new().unwrap();
let issuer = server.uri();
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true);
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
// Get state via test-only endpoint
let state_url = format!("http://127.0.0.1:{port}/__test/state");
@@ -294,7 +309,7 @@ async fn login_server_needs_setup_true_and_params_present() {
let codex_home = TempDir::new().unwrap();
let issuer = server.uri();
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true);
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
let state_url = format!("http://127.0.0.1:{port}/__test/state");
let (_s, state, _) = http_get(&state_url);
assert!(!state.is_empty());
@@ -317,7 +332,7 @@ async fn login_server_id_token_fallback_for_org_and_project() {
let codex_home = TempDir::new().unwrap();
let issuer = server.uri();
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true);
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
let state_url = format!("http://127.0.0.1:{port}/__test/state");
let (_s, state, _) = http_get(&state_url);
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
@@ -337,7 +352,7 @@ async fn login_server_skips_exchange_when_no_org_or_project() {
let codex_home = TempDir::new().unwrap();
let issuer = server.uri();
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true);
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
let state_url = format!("http://127.0.0.1:{port}/__test/state");
let (_s, state, _) = http_get(&state_url);
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
@@ -363,12 +378,13 @@ async fn login_server_state_mismatch() {
let server = start_mock_oauth_server(MockBehavior::Noop).await;
let codex_home = TempDir::new().unwrap();
let issuer = server.uri();
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, false);
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state=wrong");
let (status, body) = http_get_follow_redirect(&cb_url);
let (status, body, content_type) = http_get_with_ct(&cb_url);
assert_eq!(status, 400);
assert!(body.contains("State parameter mismatch") || body.is_empty());
assert!(body.contains("State parameter mismatch"));
assert!(content_type.unwrap_or_default().to_ascii_lowercase().starts_with("text/html"));
// Stop server
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
@@ -381,7 +397,7 @@ async fn login_server_missing_code() {
let server = start_mock_oauth_server(MockBehavior::Noop).await;
let codex_home = TempDir::new().unwrap();
let issuer = server.uri();
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, false);
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
// Fetch state
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
@@ -391,8 +407,10 @@ async fn login_server_missing_code() {
.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);
let (status, body, content_type) = http_get_with_ct(&cb_url);
assert_eq!(status, 400);
assert!(body.contains("Missing authorization code"));
assert!(content_type.unwrap_or_default().to_ascii_lowercase().starts_with("text/html"));
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
handle.join().unwrap().unwrap();
}
@@ -403,15 +421,17 @@ async fn login_server_token_exchange_error() {
let server = start_mock_oauth_server(MockBehavior::TokenError).await;
let codex_home = TempDir::new().unwrap();
let issuer = server.uri();
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, false);
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
.call()
.expect("get state")
.into_string()
.unwrap();
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
let (status, _body) = http_get_follow_redirect(&cb_url);
let (status, body, content_type) = http_get_with_ct(&cb_url);
assert_eq!(status, 500);
assert!(body.contains("Token exchange failed"));
assert!(content_type.unwrap_or_default().to_ascii_lowercase().starts_with("text/html"));
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
handle.join().unwrap().unwrap();
}
@@ -423,7 +443,7 @@ async fn login_server_credit_redemption_best_effort() {
let server = start_mock_oauth_server(MockBehavior::Success).await;
let codex_home = TempDir::new().unwrap();
let issuer = server.uri();
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true);
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
.call()
.expect("get state")