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