mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
cleaning
This commit is contained in:
@@ -330,14 +330,11 @@ pub async fn login_with_chatgpt(
|
||||
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
|
||||
|
||||
@@ -89,7 +89,7 @@ pub(crate) fn maybe_redeem_credits(
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
|
||||
// Subscription active > 7 days check (parity with Python script)
|
||||
// Subscription active > 7 days check
|
||||
if let Some(sub_start_str) = auth_claims
|
||||
.get("chatgpt_subscription_active_start")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use chrono::Utc;
|
||||
//
|
||||
use rand::RngCore;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
@@ -23,11 +23,8 @@ use crate::success_url::build_success_url;
|
||||
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");
|
||||
|
||||
// PKCE helpers are in crate::pkce
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CodeExchangeResponse {
|
||||
id_token: String,
|
||||
@@ -35,16 +32,7 @@ struct CodeExchangeResponse {
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenExchangeResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
// JWT helpers are in crate::jwt_utils
|
||||
|
||||
// Auth file writer is in crate::auth_file
|
||||
|
||||
// Credit redemption logic is in crate::redeem
|
||||
//
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoginServerOptions {
|
||||
@@ -61,6 +49,72 @@ pub struct LoginServerOptions {
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
/// Extracts commonly used claims from ID and access tokens.
|
||||
/// - account_id is taken from the ID token.
|
||||
/// - org_id/project_id prefer ID token, falling back to access token.
|
||||
/// - plan_type comes from the access token.
|
||||
/// - needs_setup is computed from (completed_platform_onboarding, is_org_owner) with the same precedence as org/project.
|
||||
fn extract_login_context(
|
||||
id_token: &str,
|
||||
access_token: &str,
|
||||
) -> (
|
||||
Option<String>, // account_id
|
||||
Option<String>, // org_id
|
||||
Option<String>, // project_id
|
||||
bool, // needs_setup
|
||||
Option<String>, // plan_type
|
||||
) {
|
||||
let id_claims = parse_jwt_claims(id_token);
|
||||
let access_claims = parse_jwt_claims(access_token);
|
||||
let id_auth_claims = id_claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
let access_auth_claims = access_claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
|
||||
let account_id = id_auth_claims
|
||||
.get("chatgpt_account_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
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()))
|
||||
.map(|s| s.to_string());
|
||||
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()))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
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 = 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);
|
||||
let needs_setup = !completed_onboarding && is_org_owner;
|
||||
|
||||
let plan_type = access_auth_claims
|
||||
.get("chatgpt_plan_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
(account_id, org_id, project_id, needs_setup, plan_type)
|
||||
}
|
||||
|
||||
fn default_url_base(port: u16) -> String {
|
||||
format!("http://localhost:{port}")
|
||||
}
|
||||
@@ -250,206 +304,10 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
|
||||
}
|
||||
};
|
||||
|
||||
// Extract account_id from id_token claims
|
||||
let id_claims = parse_jwt_claims(&tokens.id_token);
|
||||
let auth_claims = id_claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
let account_id = auth_claims
|
||||
.get("chatgpt_account_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let (account_id, org_id, project_id, needs_setup, plan_type) =
|
||||
extract_login_context(&tokens.id_token, &tokens.access_token);
|
||||
|
||||
// Parse access token claims to compute redirect target
|
||||
let access_claims = parse_jwt_claims(&tokens.access_token);
|
||||
let access_auth_claims = access_claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
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 = 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 (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 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
|
||||
}
|
||||
}
|
||||
};
|
||||
let api_key_opt: Option<String> = None;
|
||||
|
||||
// Persist auth.json
|
||||
if let Err(e) = write_auth_file(
|
||||
@@ -487,9 +345,9 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
|
||||
let success_url = build_success_url(
|
||||
&url_base,
|
||||
Some(&tokens.id_token),
|
||||
org_id,
|
||||
project_id,
|
||||
plan_type,
|
||||
org_id.as_deref(),
|
||||
project_id.as_deref(),
|
||||
plan_type.as_deref(),
|
||||
needs_setup,
|
||||
platform_url,
|
||||
)
|
||||
@@ -614,103 +472,10 @@ pub fn process_callback_headless(
|
||||
return Err(std::io::Error::other("token exchange failed"));
|
||||
}
|
||||
|
||||
// Extract claims with Python-parity precedence: prefer ID-token claims; fall back to access-token
|
||||
let id_claims = parse_jwt_claims(&id_token);
|
||||
let id_auth = id_claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.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);
|
||||
let access_auth = access_claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
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 = 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;
|
||||
let (account_id, org_id, project_id, needs_setup, plan_type) =
|
||||
extract_login_context(&id_token, &access_token);
|
||||
|
||||
// 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 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
|
||||
};
|
||||
let api_key = None;
|
||||
|
||||
// Persist auth.json
|
||||
write_auth_file(
|
||||
@@ -743,13 +508,9 @@ pub fn process_callback_headless(
|
||||
let success_url = build_success_url(
|
||||
&base,
|
||||
Some(&id_token),
|
||||
org_id,
|
||||
project_id,
|
||||
if plan_type.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(plan_type)
|
||||
},
|
||||
org_id.as_deref(),
|
||||
project_id.as_deref(),
|
||||
plan_type.as_deref(),
|
||||
needs_setup,
|
||||
platform_url,
|
||||
)
|
||||
@@ -761,4 +522,4 @@ pub fn process_callback_headless(
|
||||
})
|
||||
}
|
||||
|
||||
// Success URL builder is in crate::success_url
|
||||
//
|
||||
|
||||
@@ -83,8 +83,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"
|
||||
}));
|
||||
// Token-exchange to API key
|
||||
http.queue(json!({"access_token": "sk-xyz"}));
|
||||
// Credits redeem
|
||||
http.queue(json!({"granted_chatgpt_subscriber_api_credits": 5}));
|
||||
|
||||
@@ -93,7 +91,7 @@ fn headless_success_writes_auth_and_url() {
|
||||
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();
|
||||
assert_eq!(v["OPENAI_API_KEY"].as_str(), Some("sk-xyz"));
|
||||
assert!(v["OPENAI_API_KEY"].is_null());
|
||||
}
|
||||
|
||||
// 2) State mismatch errors
|
||||
@@ -146,8 +144,6 @@ fn headless_credit_redemption_best_effort() {
|
||||
"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"
|
||||
}));
|
||||
// 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();
|
||||
@@ -179,8 +175,6 @@ fn headless_id_token_fallback_for_org_and_project() {
|
||||
})),
|
||||
"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}));
|
||||
|
||||
|
||||
@@ -250,162 +250,8 @@ fn start_mock_oauth_server(port: u16, behavior: MockBehavior) {
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Old token-exchange fallback behavior removed
|
||||
// Old token-exchange fallback behavior removed
|
||||
}
|
||||
} else if request.method() == &tiny_http::Method::Post
|
||||
&& url.starts_with("/v1/billing/redeem_credits")
|
||||
@@ -437,8 +283,7 @@ enum MockBehavior {
|
||||
SuccessIdClaimsOrgProject,
|
||||
TokenError,
|
||||
MissingOrgSkipExchange,
|
||||
IdMissingOrgThenAccessSucceeds,
|
||||
IdMissingOrgThenAccessFails,
|
||||
// Old token-exchange fallback behaviors removed
|
||||
}
|
||||
|
||||
fn make_fake_jwt(payload: serde_json::Value) -> String {
|
||||
@@ -522,7 +367,7 @@ fn login_server_happy_path() {
|
||||
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_eq!(v["OPENAI_API_KEY"].as_str(), Some("sk-test-123"));
|
||||
assert!(v["OPENAI_API_KEY"].is_null());
|
||||
assert!(v["tokens"]["id_token"].as_str().is_some());
|
||||
}
|
||||
// 1b) needs_setup=true when onboarding incomplete and is_org_owner=true
|
||||
@@ -643,79 +488,7 @@ fn login_server_skips_exchange_when_no_org_or_project() {
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user