feat: codex app-server --default-chatgpt-proxy-auth

This commit is contained in:
Michael Bolin
2026-01-30 02:18:45 -08:00
parent 4f2a604b04
commit 87515b095d
18 changed files with 347 additions and 74 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -1084,6 +1084,7 @@ dependencies = [
"axum",
"base64",
"chrono",
"clap",
"codex-app-server-protocol",
"codex-arg0",
"codex-backend-client",

View File

@@ -905,10 +905,36 @@ mod tests {
Ok(())
}
#[test]
fn serialize_account_login_chatgpt_proxy() -> Result<()> {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(6),
params: v2::LoginAccountParams::ChatgptProxy {
account_id: Some("acc-123".to_string()),
email: Some("user@example.com".to_string()),
plan_type: Some(PlanType::Pro),
},
};
assert_eq!(
json!({
"method": "account/login/start",
"id": 6,
"params": {
"type": "chatgptProxy",
"accountId": "acc-123",
"email": "user@example.com",
"planType": "pro"
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_get_account() -> Result<()> {
let request = ClientRequest::GetAccount {
request_id: RequestId::Integer(6),
request_id: RequestId::Integer(7),
params: v2::GetAccountParams {
refresh_token: false,
},
@@ -916,7 +942,7 @@ mod tests {
assert_eq!(
json!({
"method": "account/read",
"id": 6,
"id": 7,
"params": {
"refreshToken": false
}

View File

@@ -835,6 +835,20 @@ pub enum LoginAccountParams {
#[serde(rename = "chatgpt")]
#[ts(rename = "chatgpt")]
Chatgpt,
/// Tokenless ChatGPT auth via a trusted proxy.
///
/// The proxy is expected to inject the Authorization header, so Codex
/// should not set a bearer token on outgoing requests.
#[serde(rename = "chatgptProxy", rename_all = "camelCase")]
#[ts(rename = "chatgptProxy", rename_all = "camelCase")]
ChatgptProxy {
/// ChatGPT workspace/account identifier.
account_id: Option<String>,
/// Account email address.
email: Option<String>,
/// Account plan type.
plan_type: Option<PlanType>,
},
/// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.
/// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.
#[serde(rename = "chatgptAuthTokens")]
@@ -872,6 +886,9 @@ pub enum LoginAccountResponse {
/// URL the client should open in a browser to initiate the OAuth flow.
auth_url: String,
},
#[serde(rename = "chatgptProxy", rename_all = "camelCase")]
#[ts(rename = "chatgptProxy", rename_all = "camelCase")]
ChatgptProxy {},
#[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")]
#[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")]
ChatgptAuthTokens {},

View File

@@ -32,6 +32,7 @@ codex-rmcp-client = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
mcp-types = { workspace = true }

View File

@@ -144,6 +144,7 @@ use codex_core::ThreadSortKey as CoreThreadSortKey;
use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key;
use codex_core::auth::login_with_chatgpt_auth_tokens;
use codex_core::auth::login_with_chatgpt_proxy;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigService;
@@ -611,6 +612,14 @@ impl CodexMessageProcessor {
LoginAccountParams::Chatgpt => {
self.login_chatgpt_v2(request_id).await;
}
LoginAccountParams::ChatgptProxy {
account_id,
email,
plan_type,
} => {
self.login_chatgpt_proxy(request_id, account_id, email, plan_type)
.await;
}
LoginAccountParams::ChatgptAuthTokens {
id_token,
access_token,
@@ -624,7 +633,7 @@ impl CodexMessageProcessor {
fn external_auth_active_error(&self) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it."
message: "External auth is active. Use account/login/start (chatgptAuthTokens or chatgptProxy) to update it or account/logout to clear it."
.to_string(),
data: None,
}
@@ -1006,6 +1015,89 @@ impl CodexMessageProcessor {
}
}
async fn login_chatgpt_proxy(
&mut self,
request_id: RequestId,
account_id: Option<String>,
email: Option<String>,
plan_type: Option<codex_protocol::account::PlanType>,
) {
if matches!(
self.config.forced_login_method,
Some(ForcedLoginMethod::Api)
) {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "ChatGPT proxy auth is disabled. Use API key login instead.".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
// Cancel any active login attempt to avoid persisting managed auth state.
{
let mut guard = self.active_login.lock().await;
if let Some(active) = guard.take() {
drop(active);
}
}
if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref()
&& account_id.as_deref() != Some(expected_workspace)
{
let actual_workspace = account_id.as_deref().unwrap_or("<missing>");
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"ChatGPT proxy auth must use workspace {expected_workspace}, but received {actual_workspace}."
),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
if let Err(err) = login_with_chatgpt_proxy(
&self.config.codex_home,
account_id.as_deref(),
email.as_deref(),
plan_type,
self.config.cli_auth_credentials_store_mode,
) {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to set ChatGPT proxy auth: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
self.auth_manager.reload();
self.outgoing
.send_response(request_id, LoginAccountResponse::ChatgptProxy {})
.await;
let payload_login_completed = AccountLoginCompletedNotification {
login_id: None,
success: true,
error: None,
};
self.outgoing
.send_server_notification(ServerNotification::AccountLoginCompleted(
payload_login_completed,
))
.await;
let payload_v2 = AccountUpdatedNotification {
auth_mode: self.auth_manager.get_auth_mode(),
};
self.outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
.await;
}
async fn login_chatgpt_auth_tokens(
&mut self,
request_id: RequestId,
@@ -1193,19 +1285,16 @@ impl CodexMessageProcessor {
match self.auth_manager.auth().await {
Some(auth) => {
let auth_mode = auth.api_auth_mode();
let (reported_auth_method, token_opt) = match auth.get_token() {
Ok(token) if !token.is_empty() => {
let tok = if include_token { Some(token) } else { None };
(Some(auth_mode), tok)
}
Ok(_) => (None, None),
let token_opt = match auth.bearer_token() {
Ok(Some(token)) if include_token && !token.is_empty() => Some(token),
Ok(_) => None,
Err(err) => {
tracing::warn!("failed to get token for auth status: {err}");
(None, None)
tracing::warn!("failed to get bearer token for auth status: {err}");
None
}
};
GetAuthStatusResponse {
auth_method: reported_auth_method,
auth_method: Some(auth_mode),
auth_token: token_opt,
requires_openai_auth: Some(true),
}
@@ -1238,31 +1327,20 @@ impl CodexMessageProcessor {
return;
}
let account = match self.auth_manager.auth_cached() {
Some(auth) => Some(match auth {
CodexAuth::ApiKey(_) => Account::ApiKey {},
CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) => {
let email = auth.get_account_email();
let plan_type = auth.account_plan_type();
match (email, plan_type) {
(Some(email), Some(plan_type)) => Account::Chatgpt { email, plan_type },
_ => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message:
"email and plan type are required for chatgpt authentication"
.to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
}
}
}),
None => None,
};
let account = self.auth_manager.auth_cached().map(|auth| match auth {
CodexAuth::ApiKey(_) => Account::ApiKey {},
CodexAuth::ChatGpt(_)
| CodexAuth::ChatGptAuthTokens(_)
| CodexAuth::ChatGptProxy(_) => {
let email = auth
.get_account_email()
.unwrap_or_else(|| "unknown".to_string());
let plan_type = auth
.account_plan_type()
.unwrap_or(codex_protocol::account::PlanType::Unknown);
Account::Chatgpt { email, plan_type }
}
});
let response = GetAccountResponse {
account,

View File

@@ -168,6 +168,7 @@ pub async fn run_main(
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
default_analytics_enabled: bool,
default_chatgpt_proxy_auth: bool,
) -> IoResult<()> {
// Set up channels.
let (incoming_tx, mut incoming_rx) = mpsc::channel::<JSONRPCMessage>(CHANNEL_CAPACITY);
@@ -296,6 +297,7 @@ pub async fn run_main(
std::sync::Arc::new(config),
cli_overrides,
loader_overrides,
default_chatgpt_proxy_auth,
feedback.clone(),
config_warnings,
);

View File

@@ -1,3 +1,4 @@
use clap::Parser;
use codex_app_server::run_main;
use codex_arg0::arg0_dispatch_or_else;
use codex_common::CliConfigOverrides;
@@ -8,8 +9,20 @@ use std::path::PathBuf;
// managed config file without writing to /etc.
const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH";
#[derive(Debug, Parser, Default, Clone)]
#[command(bin_name = "codex-app-server")]
struct AppServerCli {
#[clap(flatten)]
config_overrides: CliConfigOverrides,
/// Seed ChatGPT proxy auth (tokenless) on startup when no auth is present.
#[arg(long = "default-chatgpt-proxy-auth")]
default_chatgpt_proxy_auth: bool,
}
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
let cli = AppServerCli::parse();
let managed_config_path = managed_config_path_from_debug_env();
let loader_overrides = LoaderOverrides {
managed_config_path,
@@ -18,9 +31,10 @@ fn main() -> anyhow::Result<()> {
run_main(
codex_linux_sandbox_exe,
CliConfigOverrides::default(),
cli.config_overrides,
loader_overrides,
false,
cli.default_chatgpt_proxy_auth,
)
.await?;
Ok(())

View File

@@ -26,10 +26,12 @@ use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequestPayload;
use codex_core::AuthManager;
use codex_core::ThreadManager;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::ExternalAuthRefreshContext;
use codex_core::auth::ExternalAuthRefreshReason;
use codex_core::auth::ExternalAuthRefresher;
use codex_core::auth::ExternalAuthTokens;
use codex_core::auth::login_with_chatgpt_proxy;
use codex_core::config::Config;
use codex_core::config_loader::LoaderOverrides;
use codex_core::default_client::SetOriginatorError;
@@ -38,11 +40,13 @@ use codex_core::default_client::get_codex_user_agent;
use codex_core::default_client::set_default_originator;
use codex_feedback::CodexFeedback;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::protocol::SessionSource;
use tokio::sync::broadcast;
use tokio::time::Duration;
use tokio::time::timeout;
use toml::Value as TomlValue;
use tracing::warn;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
@@ -115,6 +119,7 @@ impl MessageProcessor {
config: Arc<Config>,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
default_chatgpt_proxy_auth: bool,
feedback: CodexFeedback,
config_warnings: Vec<ConfigWarningNotification>,
) -> Self {
@@ -124,6 +129,23 @@ impl MessageProcessor {
false,
config.cli_auth_credentials_store_mode,
);
if default_chatgpt_proxy_auth
&& auth_manager.auth_cached().is_none()
&& !matches!(config.forced_login_method, Some(ForcedLoginMethod::Api))
{
let account_id = config.forced_chatgpt_workspace_id.as_deref();
if let Err(err) = login_with_chatgpt_proxy(
&config.codex_home,
account_id,
None,
None,
AuthCredentialsStoreMode::Ephemeral,
) {
warn!("failed to seed default ChatGPT proxy auth: {err}");
} else {
auth_manager.reload();
}
}
auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone());
auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge {
outgoing: outgoing.clone(),

View File

@@ -163,6 +163,7 @@ pub fn write_chatgpt_auth(
openai_api_key: None,
tokens: Some(tokens),
last_refresh,
chatgpt_proxy: None,
};
save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json")

View File

@@ -75,10 +75,10 @@ impl Client {
}
pub fn from_auth(base_url: impl Into<String>, auth: &CodexAuth) -> Result<Self> {
let token = auth.get_token().map_err(anyhow::Error::from)?;
let mut client = Self::new(base_url)?
.with_user_agent(get_codex_user_agent())
.with_bearer_token(token);
let mut client = Self::new(base_url)?.with_user_agent(get_codex_user_agent());
if let Some(token) = auth.bearer_token().map_err(anyhow::Error::from)? {
client = client.with_bearer_token(token);
}
if let Some(account_id) = auth.get_account_id() {
client = client.with_chatgpt_account_id(account_id);
}

View File

@@ -283,6 +283,10 @@ struct AppServerCommand {
/// See https://developers.openai.com/codex/config-advanced/#metrics for more details.
#[arg(long = "analytics-default-enabled")]
analytics_default_enabled: bool,
/// Seed ChatGPT proxy auth (tokenless) on startup when no auth is present.
#[arg(long = "default-chatgpt-proxy-auth")]
default_chatgpt_proxy_auth: bool,
}
#[derive(Debug, clap::Subcommand)]
@@ -533,6 +537,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
root_config_overrides,
codex_core::config_loader::LoaderOverrides::default(),
app_server_cli.analytics_default_enabled,
app_server_cli.default_chatgpt_proxy_auth,
)
.await?;
}
@@ -1241,6 +1246,19 @@ mod tests {
assert!(app_server.analytics_default_enabled);
}
#[test]
fn app_server_default_chatgpt_proxy_auth_disabled_without_flag() {
let app_server = app_server_from_args(["codex", "app-server"].as_ref());
assert!(!app_server.default_chatgpt_proxy_auth);
}
#[test]
fn app_server_default_chatgpt_proxy_auth_enabled_with_flag() {
let app_server =
app_server_from_args(["codex", "app-server", "--default-chatgpt-proxy-auth"].as_ref());
assert!(app_server.default_chatgpt_proxy_auth);
}
#[test]
fn features_enable_parses_feature_name() {
let cli = MultitoolCli::try_parse_from(["codex", "features", "enable", "unified_exec"])

View File

@@ -177,9 +177,9 @@ pub(crate) fn auth_provider_from_auth(
}
if let Some(auth) = auth {
let token = auth.get_token()?;
let token = auth.bearer_token()?;
Ok(CoreAuthProvider {
token: Some(token),
token,
account_id: auth.get_account_id(),
})
} else {

View File

@@ -21,6 +21,7 @@ use codex_protocol::config_types::ForcedLoginMethod;
pub use crate::auth::storage::AuthCredentialsStoreMode;
pub use crate::auth::storage::AuthDotJson;
use crate::auth::storage::AuthStorageBackend;
use crate::auth::storage::ChatGptProxyAccount;
use crate::auth::storage::create_auth_storage;
use crate::config::Config;
use crate::error::RefreshTokenFailedError;
@@ -53,6 +54,7 @@ pub enum CodexAuth {
ApiKey(ApiKeyAuth),
ChatGpt(ChatGptAuth),
ChatGptAuthTokens(ChatGptAuthTokens),
ChatGptProxy(ChatGptProxy),
}
#[derive(Debug, Clone)]
@@ -71,6 +73,11 @@ pub struct ChatGptAuthTokens {
state: ChatGptAuthState,
}
#[derive(Debug, Clone)]
pub struct ChatGptProxy {
account: ChatGptProxyAccount,
}
#[derive(Debug, Clone)]
struct ChatGptAuthState {
auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
@@ -160,6 +167,12 @@ impl CodexAuth {
return Ok(CodexAuth::from_api_key_with_client(api_key, client));
}
if let Some(proxy_account) = auth_dot_json.chatgpt_proxy.clone() {
return Ok(Self::ChatGptProxy(ChatGptProxy {
account: proxy_account,
}));
}
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
let state = ChatGptAuthState {
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
@@ -189,14 +202,16 @@ impl CodexAuth {
pub fn internal_auth_mode(&self) -> AuthMode {
match self {
Self::ApiKey(_) => AuthMode::ApiKey,
Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => AuthMode::ChatGPT,
Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) | Self::ChatGptProxy(_) => {
AuthMode::ChatGPT
}
}
}
pub fn api_auth_mode(&self) -> ApiAuthMode {
match self {
Self::ApiKey(_) => ApiAuthMode::ApiKey,
Self::ChatGpt(_) => ApiAuthMode::ChatGPT,
Self::ChatGpt(_) | Self::ChatGptProxy(_) => ApiAuthMode::ChatGPT,
Self::ChatGptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens,
}
}
@@ -213,7 +228,7 @@ impl CodexAuth {
pub fn api_key(&self) -> Option<&str> {
match self {
Self::ApiKey(auth) => Some(auth.api_key.as_str()),
Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => None,
Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) | Self::ChatGptProxy(_) => None,
}
}
@@ -230,25 +245,40 @@ impl CodexAuth {
}
}
/// Returns the token string used for bearer authentication.
pub fn get_token(&self) -> Result<String, std::io::Error> {
/// Returns the token string used for bearer authentication, if available.
pub fn bearer_token(&self) -> Result<Option<String>, std::io::Error> {
match self {
Self::ApiKey(auth) => Ok(auth.api_key.clone()),
Self::ApiKey(auth) => Ok(Some(auth.api_key.clone())),
Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => {
let access_token = self.get_token_data()?.access_token;
Ok(access_token)
Ok(Some(access_token))
}
Self::ChatGptProxy(_) => Ok(None),
}
}
/// Returns the token string used for bearer authentication.
pub fn get_token(&self) -> Result<String, std::io::Error> {
let Some(token) = self.bearer_token()? else {
return Err(std::io::Error::other("Bearer token is not available."));
};
Ok(token)
}
/// Returns `None` if `is_chatgpt_auth()` is false.
pub fn get_account_id(&self) -> Option<String> {
self.get_current_token_data().and_then(|t| t.account_id)
match self {
Self::ChatGptProxy(proxy) => proxy.account.account_id.clone(),
_ => self.get_current_token_data().and_then(|t| t.account_id),
}
}
/// Returns `None` if `is_chatgpt_auth()` is false.
pub fn get_account_email(&self) -> Option<String> {
self.get_current_token_data().and_then(|t| t.id_token.email)
match self {
Self::ChatGptProxy(proxy) => proxy.account.email.clone(),
_ => self.get_current_token_data().and_then(|t| t.id_token.email),
}
}
/// Account-facing plan classification derived from the current token.
@@ -256,6 +286,9 @@ impl CodexAuth {
/// mapped from the ID token's internal plan value. Prefer this when you
/// need to make UI or product decisions based on the user's subscription.
pub fn account_plan_type(&self) -> Option<AccountPlanType> {
if let Self::ChatGptProxy(proxy) = self {
return proxy.account.plan_type;
}
let map_known = |kp: &InternalKnownPlan| match kp {
InternalKnownPlan::Free => AccountPlanType::Free,
InternalKnownPlan::Go => AccountPlanType::Go,
@@ -275,12 +308,22 @@ impl CodexAuth {
})
}
/// Returns the ChatGPT workspace/account identifier when available.
pub fn chatgpt_workspace_id(&self) -> Option<String> {
match self {
Self::ChatGptProxy(proxy) => proxy.account.account_id.clone(),
_ => self
.get_current_token_data()
.and_then(|t| t.id_token.chatgpt_account_id.or(t.account_id)),
}
}
/// Returns `None` if `is_chatgpt_auth()` is false.
fn get_current_auth_json(&self) -> Option<AuthDotJson> {
let state = match self {
Self::ChatGpt(auth) => &auth.state,
Self::ChatGptAuthTokens(auth) => &auth.state,
Self::ApiKey(_) => return None,
Self::ApiKey(_) | Self::ChatGptProxy(_) => return None,
};
#[expect(clippy::unwrap_used)]
state.auth_dot_json.lock().unwrap().clone()
@@ -303,6 +346,7 @@ impl CodexAuth {
account_id: Some("account_id".to_string()),
}),
last_refresh: Some(Utc::now()),
chatgpt_proxy: None,
};
let client = crate::default_client::create_client();
@@ -382,6 +426,7 @@ pub fn login_with_api_key(
openai_api_key: Some(api_key.to_string()),
tokens: None,
last_refresh: None,
chatgpt_proxy: None,
};
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
}
@@ -400,6 +445,28 @@ pub fn login_with_chatgpt_auth_tokens(
)
}
/// Writes a tokenless ChatGPT proxy auth payload.
pub fn login_with_chatgpt_proxy(
codex_home: &Path,
account_id: Option<&str>,
email: Option<&str>,
plan_type: Option<AccountPlanType>,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> {
let auth_dot_json = AuthDotJson {
auth_mode: Some(ApiAuthMode::ChatGPT),
openai_api_key: None,
tokens: None,
last_refresh: None,
chatgpt_proxy: Some(ChatGptProxyAccount {
account_id: account_id.map(str::to_string),
email: email.map(str::to_string),
plan_type,
}),
};
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
}
/// Persist the provided auth payload using the specified backend.
pub fn save_auth(
codex_home: &Path,
@@ -461,23 +528,10 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> {
return Ok(());
}
let token_data = match auth.get_token_data() {
Ok(data) => data,
Err(err) => {
return logout_with_message(
&config.codex_home,
format!(
"Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out."
),
config.cli_auth_credentials_store_mode,
);
}
};
// workspace is the external identifier for account id.
let chatgpt_account_id = token_data.id_token.chatgpt_account_id.as_deref();
if chatgpt_account_id != Some(expected_account_id) {
let message = match chatgpt_account_id {
// Workspace is the external identifier for account id.
let chatgpt_account_id = auth.chatgpt_workspace_id();
if chatgpt_account_id.as_deref() != Some(expected_account_id) {
let message = match chatgpt_account_id.as_deref() {
Some(actual) => format!(
"Login is restricted to workspace {expected_account_id}, but current credentials belong to {actual}. Logging out."
),
@@ -731,6 +785,7 @@ impl AuthDotJson {
openai_api_key: None,
tokens: Some(tokens),
last_refresh: Some(Utc::now()),
chatgpt_proxy: None,
}
}
@@ -1143,7 +1198,7 @@ impl AuthManager {
self.reload();
Ok(())
}
CodexAuth::ApiKey(_) => Ok(()),
CodexAuth::ApiKey(_) | CodexAuth::ChatGptProxy(_) => Ok(()),
}
}
@@ -1399,6 +1454,7 @@ mod tests {
account_id: None,
}),
last_refresh: Some(last_refresh),
chatgpt_proxy: None,
},
auth_dot_json
);
@@ -1432,6 +1488,7 @@ mod tests {
openai_api_key: Some("sk-test-key".to_string()),
tokens: None,
last_refresh: None,
chatgpt_proxy: None,
};
super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?;
let auth_file = get_auth_file(dir.path());

View File

@@ -23,6 +23,7 @@ use crate::token_data::TokenData;
use codex_app_server_protocol::AuthMode;
use codex_keyring_store::DefaultKeyringStore;
use codex_keyring_store::KeyringStore;
use codex_protocol::account::PlanType as AccountPlanType;
use once_cell::sync::Lazy;
/// Determine where Codex should store CLI auth credentials.
@@ -54,6 +55,23 @@ pub struct AuthDotJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_refresh: Option<DateTime<Utc>>,
/// ChatGPT account metadata supplied by a trusted proxy.
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "chatgptProxy"
)]
pub chatgpt_proxy: Option<ChatGptProxyAccount>,
}
/// Account metadata for the tokenless ChatGPT proxy auth mode.
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ChatGptProxyAccount {
pub account_id: Option<String>,
pub email: Option<String>,
pub plan_type: Option<AccountPlanType>,
}
pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf {
@@ -353,6 +371,7 @@ mod tests {
openai_api_key: Some("test-key".to_string()),
tokens: None,
last_refresh: Some(Utc::now()),
chatgpt_proxy: None,
};
storage
@@ -373,6 +392,7 @@ mod tests {
openai_api_key: Some("test-key".to_string()),
tokens: None,
last_refresh: Some(Utc::now()),
chatgpt_proxy: None,
};
let file = get_auth_file(codex_home.path());
@@ -395,6 +415,7 @@ mod tests {
openai_api_key: Some("sk-test-key".to_string()),
tokens: None,
last_refresh: None,
chatgpt_proxy: None,
};
let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File);
storage.save(&auth_dot_json)?;
@@ -418,6 +439,7 @@ mod tests {
openai_api_key: Some("sk-ephemeral".to_string()),
tokens: None,
last_refresh: Some(Utc::now()),
chatgpt_proxy: None,
};
storage.save(&auth_dot_json)?;
@@ -516,6 +538,7 @@ mod tests {
account_id: Some(format!("{prefix}-account-id")),
}),
last_refresh: None,
chatgpt_proxy: None,
}
}
@@ -532,6 +555,7 @@ mod tests {
openai_api_key: Some("sk-test".to_string()),
tokens: None,
last_refresh: None,
chatgpt_proxy: None,
};
seed_keyring_with_auth(
&mock_keyring,
@@ -574,6 +598,7 @@ mod tests {
account_id: Some("account".to_string()),
}),
last_refresh: Some(Utc::now()),
chatgpt_proxy: None,
};
storage.save(&auth)?;

View File

@@ -39,7 +39,7 @@ fn codex_apps_mcp_bearer_token_env_var() -> Option<String> {
}
fn codex_apps_mcp_bearer_token(auth: Option<&CodexAuth>) -> Option<String> {
let token = auth.and_then(|auth| auth.get_token().ok())?;
let token = auth.and_then(|auth| auth.bearer_token().ok()).flatten()?;
let token = token.trim();
if token.is_empty() {
None

View File

@@ -55,6 +55,7 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> {
openai_api_key: None,
tokens: Some(initial_tokens.clone()),
last_refresh: Some(initial_last_refresh),
chatgpt_proxy: None,
};
ctx.write_auth(&initial_auth)?;
@@ -117,6 +118,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> {
openai_api_key: None,
tokens: Some(initial_tokens.clone()),
last_refresh: Some(initial_last_refresh),
chatgpt_proxy: None,
};
ctx.write_auth(&initial_auth)?;
@@ -163,6 +165,7 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> {
openai_api_key: None,
tokens: Some(initial_tokens.clone()),
last_refresh: Some(stale_refresh),
chatgpt_proxy: None,
};
ctx.write_auth(&initial_auth)?;
@@ -222,6 +225,7 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re
openai_api_key: None,
tokens: Some(initial_tokens.clone()),
last_refresh: Some(initial_last_refresh),
chatgpt_proxy: None,
};
ctx.write_auth(&initial_auth)?;
@@ -272,6 +276,7 @@ async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()>
openai_api_key: None,
tokens: Some(initial_tokens.clone()),
last_refresh: Some(initial_last_refresh),
chatgpt_proxy: None,
};
ctx.write_auth(&initial_auth)?;
@@ -324,6 +329,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> {
openai_api_key: None,
tokens: Some(initial_tokens.clone()),
last_refresh: Some(initial_last_refresh),
chatgpt_proxy: None,
};
ctx.write_auth(&initial_auth)?;
@@ -333,6 +339,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> {
openai_api_key: None,
tokens: Some(disk_tokens.clone()),
last_refresh: Some(initial_last_refresh),
chatgpt_proxy: None,
};
save_auth(
ctx.codex_home.path(),
@@ -416,6 +423,7 @@ async fn unauthorized_recovery_skips_reload_on_account_mismatch() -> Result<()>
openai_api_key: None,
tokens: Some(initial_tokens.clone()),
last_refresh: Some(initial_last_refresh),
chatgpt_proxy: None,
};
ctx.write_auth(&initial_auth)?;
@@ -431,6 +439,7 @@ async fn unauthorized_recovery_skips_reload_on_account_mismatch() -> Result<()>
openai_api_key: None,
tokens: Some(disk_tokens),
last_refresh: Some(initial_last_refresh),
chatgpt_proxy: None,
};
save_auth(
ctx.codex_home.path(),
@@ -495,6 +504,7 @@ async fn unauthorized_recovery_requires_chatgpt_auth() -> Result<()> {
openai_api_key: Some("sk-test".to_string()),
tokens: None,
last_refresh: None,
chatgpt_proxy: None,
};
ctx.write_auth(&auth)?;

View File

@@ -564,6 +564,7 @@ pub(crate) async fn persist_tokens_async(
openai_api_key: api_key,
tokens: Some(tokens),
last_refresh: Some(Utc::now()),
chatgpt_proxy: None,
};
save_auth(&codex_home, &auth, auth_credentials_store_mode)
})

View File

@@ -91,7 +91,7 @@ pub(crate) fn compose_account_display(
let auth = auth_manager.auth_cached()?;
match auth {
CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) => {
CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) | CodexAuth::ChatGptProxy(_) => {
let email = auth.get_account_email();
let plan = plan
.map(|plan_type| title_case(format!("{plan_type:?}").as_str()))