refactor: unify external auth resolution (#31421)

## Summary

External auth had two paths: provider-command credentials were resolved
through `ExternalAuth`, while app-provided ChatGPT credentials were
installed separately and only used the provider for refresh.
`ExternalAuth` also declared an auth mode independently from the
`CodexAuth` value it returned, so the declaration and credentials could
disagree.

This change makes the provider-owned `CodexAuth` authoritative for
initial resolution, credential kind, and refresh.

- remove `ExternalAuth::auth_mode` and require providers to return their
current auth from `resolve`
- route external auth registration, resolution, and unauthorized refresh
through `AuthManager::set_external_auth`
- keep the last resolved credential with its provider so synchronous
consumers and unauthorized recovery observe the credential's actual mode
- make the app-server bridge own both initial and refreshed ChatGPT
credentials, and detach it on logout
- keep model listing free of auth-refresh side effects, including in
offline mode

Provider-command auth still follows its configured cache interval.
App-provided ChatGPT auth still asks the parent app once after a `401`
and retries the request once.

Stacked on #31355.

## Testing

- `just test -p codex-login`
- `just test -p codex-models-manager`
- focused `codex-app-server` tests for external login/logout,
unauthorized refresh, and workspace mismatch
This commit is contained in:
pakrym-oai authored and GitHub committed 2026-07-07 17:05:05 -07:00
1 parent 77b766c6ee
commit e60af81b4f
12 files changed
+433 -309

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
use std::sync::Arc;
use std::sync::RwLock;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_app_server_protocol::ServerRequestPayload;
use codex_login::CodexAuth;
use codex_login::ExternalAuthFuture;
use codex_login::auth::ExternalAuth;
use codex_login::auth::ExternalAuthRefreshContext;
use codex_login::auth::ExternalAuthRefreshReason;
use tokio::time::Duration;
use tokio::time::timeout;
use crate::outgoing_message::OutgoingMessageSender;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) struct ExternalAuthBridge {
outgoing: Arc<OutgoingMessageSender>,
auth: RwLock<CodexAuth>,
}
impl ExternalAuthBridge {
pub(crate) fn new(outgoing: Arc<OutgoingMessageSender>, auth: CodexAuth) -> Self {
Self {
outgoing,
auth: RwLock::new(auth),
}
}
async fn refresh(&self, context: ExternalAuthRefreshContext) -> std::io::Result<CodexAuth> {
let reason = match context.reason {
ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized,
};
let params = ChatgptAuthTokensRefreshParams {
reason,
previous_account_id: context.previous_account_id,
};
let (request_id, rx) = self
.outgoing
.send_request(ServerRequestPayload::ChatgptAuthTokensRefresh(params))
.await;
let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await {
Ok(result) => {
let result = result.map_err(|err| {
std::io::Error::other(format!("auth refresh request canceled: {err}"))
})?;
result.map_err(|err| {
std::io::Error::other(format!(
"auth refresh request failed: code={} message={}",
err.code, err.message
))
})?
}
Err(_) => {
let _canceled = self.outgoing.cancel_request(&request_id).await;
return Err(std::io::Error::other(format!(
"auth refresh request timed out after {}s",
EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs()
)));
}
};
let response: ChatgptAuthTokensRefreshResponse =
serde_json::from_value(result).map_err(std::io::Error::other)?;
let auth = CodexAuth::from_external_chatgpt_tokens(
response.access_token.as_str(),
response.chatgpt_account_id.as_str(),
response.chatgpt_plan_type.as_deref(),
)?;
*self
.auth
.write()
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))? = auth.clone();
Ok(auth)
}
}
impl ExternalAuth for ExternalAuthBridge {
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async {
self.auth
.read()
.map(|auth| auth.clone())
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))
})
}
fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(ExternalAuthBridge::refresh(self, context))
}
}
+1
View File
@@ -96,6 +96,7 @@ mod current_time;
mod dynamic_tools; mod dynamic_tools;
mod error_code; mod error_code;
mod extensions; mod extensions;
mod external_auth;
mod filters; mod filters;
mod fs_watch; mod fs_watch;
mod fuzzy_file_search; mod fuzzy_file_search;
@@ -50,9 +50,6 @@ use crate::transport::AppServerTransport;
use crate::transport::RemoteControlHandle; use crate::transport::RemoteControlHandle;
use codex_analytics::AnalyticsEventsClient; use codex_analytics::AnalyticsEventsClient;
use codex_analytics::AppServerRpcTransport; use codex_analytics::AppServerRpcTransport;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::ClientResponsePayload;
@@ -63,7 +60,6 @@ use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::experimental_required_message; use codex_app_server_protocol::experimental_required_message;
use codex_arg0::Arg0DispatchPaths; use codex_arg0::Arg0DispatchPaths;
use codex_chatgpt::workspace_settings; use codex_chatgpt::workspace_settings;
@@ -74,12 +70,7 @@ use codex_feedback::CodexFeedback;
use codex_goal_extension::GoalService; use codex_goal_extension::GoalService;
use codex_home::CodexHomeUserInstructionsProvider; use codex_home::CodexHomeUserInstructionsProvider;
use codex_login::AuthManager; use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::auth::ExternalAuth;
use codex_login::auth::ExternalAuthRefreshContext;
use codex_login::auth::ExternalAuthRefreshReason;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
use codex_protocol::auth::AuthMode as LoginAuthMode;
use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext; use codex_protocol::protocol::W3cTraceContext;
use codex_rollout::StateDbHandle; use codex_rollout::StateDbHandle;
@@ -95,7 +86,6 @@ use tracing::Instrument;
use crate::models_refresh_worker::ModelsRefreshWorker; use crate::models_refresh_worker::ModelsRefreshWorker;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
fn deserialize_client_request( fn deserialize_client_request(
@@ -109,77 +99,6 @@ fn deserialize_client_request(
}) })
} }
#[derive(Clone)]
struct ExternalAuthRefreshBridge {
outgoing: Arc<OutgoingMessageSender>,
}
impl ExternalAuthRefreshBridge {
fn map_reason(reason: ExternalAuthRefreshReason) -> ChatgptAuthTokensRefreshReason {
match reason {
ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized,
}
}
async fn refresh(&self, context: ExternalAuthRefreshContext) -> std::io::Result<CodexAuth> {
let params = ChatgptAuthTokensRefreshParams {
reason: Self::map_reason(context.reason),
previous_account_id: context.previous_account_id,
};
let (request_id, rx) = self
.outgoing
.send_request(ServerRequestPayload::ChatgptAuthTokensRefresh(params))
.await;
let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await {
Ok(result) => {
// Two failure scenarios:
// 1) `oneshot::Receiver` failed (sender dropped) => request canceled/channel closed.
// 2) client answered with JSON-RPC error payload => propagate code/message.
let result = result.map_err(|err| {
std::io::Error::other(format!("auth refresh request canceled: {err}"))
})?;
result.map_err(|err| {
std::io::Error::other(format!(
"auth refresh request failed: code={} message={}",
err.code, err.message
))
})?
}
Err(_) => {
let _canceled = self.outgoing.cancel_request(&request_id).await;
return Err(std::io::Error::other(format!(
"auth refresh request timed out after {}s",
EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs()
)));
}
};
let response: ChatgptAuthTokensRefreshResponse =
serde_json::from_value(result).map_err(std::io::Error::other)?;
CodexAuth::from_external_chatgpt_tokens(
response.access_token.as_str(),
response.chatgpt_account_id.as_str(),
response.chatgpt_plan_type.as_deref(),
)
}
}
impl ExternalAuth for ExternalAuthRefreshBridge {
fn auth_mode(&self) -> LoginAuthMode {
LoginAuthMode::Chatgpt
}
fn refresh(
&self,
context: ExternalAuthRefreshContext,
) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
Box::pin(ExternalAuthRefreshBridge::refresh(self, context))
}
}
pub(crate) struct MessageProcessor { pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>, outgoing: Arc<OutgoingMessageSender>,
models_refresh_worker: ModelsRefreshWorker, models_refresh_worker: ModelsRefreshWorker,
@@ -324,9 +243,6 @@ impl MessageProcessor {
remote_control_handle, remote_control_handle,
plugin_startup_tasks, plugin_startup_tasks,
} = args; } = args;
auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge {
outgoing: outgoing.clone(),
}));
let thread_state_manager = ThreadStateManager::new(); let thread_state_manager = ThreadStateManager::new();
// The thread store is intentionally process-scoped. Config reloads can // The thread store is intentionally process-scoped. Config reloads can
// affect per-thread behavior, but they must not move newly started, // affect per-thread behavior, but they must not move newly started,
@@ -386,7 +386,6 @@ use codex_login::LoginSuccessPage;
use codex_login::LoginSuccessPageBrand; use codex_login::LoginSuccessPageBrand;
use codex_login::ServerOptions as LoginServerOptions; use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle; use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
use codex_login::complete_device_code_login; use codex_login::complete_device_code_login;
use codex_login::login_with_api_key; use codex_login::login_with_api_key;
use codex_login::oauth_client_id; use codex_login::oauth_client_id;
@@ -1,5 +1,6 @@
use super::*; use super::*;
use crate::auth_mode::auth_mode_to_api; use crate::auth_mode::auth_mode_to_api;
use crate::external_auth::ExternalAuthBridge;
use chrono::DateTime; use chrono::DateTime;
mod rate_limit_resets; mod rate_limit_resets;
@@ -654,14 +655,19 @@ impl AccountRequestProcessor {
))); )));
} }
login_with_chatgpt_auth_tokens( let auth = CodexAuth::from_external_chatgpt_tokens(
&self.config.codex_home,
&access_token, &access_token,
&chatgpt_account_id, &chatgpt_account_id,
chatgpt_plan_type.as_deref(), chatgpt_plan_type.as_deref(),
) )
.map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?;
self.auth_manager.reload().await; self.auth_manager
.set_external_auth(Arc::new(ExternalAuthBridge::new(
Arc::clone(&self.outgoing),
auth,
)))
.await
.map_err(|err| internal_error(format!("failed to set external auth: {err}")))?;
self.config_manager.replace_cloud_config_bundle_loader( self.config_manager.replace_cloud_config_bundle_loader(
self.auth_manager.clone(), self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(), self.config.chatgpt_base_url.clone(),
@@ -328,6 +328,27 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
} }
); );
let logout_id = mcp.send_logout_account_request().await?;
let logout_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(logout_id)),
)
.await??;
let _: LogoutAccountResponse = to_response(logout_resp)?;
let get_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let get_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(get_id)),
)
.await??;
let account: GetAccountResponse = to_response(get_resp)?;
assert_eq!(account.account, None);
Ok(()) Ok(())
} }
+90 -1
View File
@@ -8,7 +8,9 @@ use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use anyhow::bail; use anyhow::bail;
use app_test_support::ChatGptAuthFixture; use app_test_support::ChatGptAuthFixture;
use app_test_support::ChatGptIdTokenClaims;
use app_test_support::TestAppServer; use app_test_support::TestAppServer;
use app_test_support::encode_id_token;
use app_test_support::to_response; use app_test_support::to_response;
use app_test_support::write_chatgpt_auth; use app_test_support::write_chatgpt_auth;
use axum::Json; use axum::Json;
@@ -29,6 +31,7 @@ use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RequestId; use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartParams;
@@ -156,6 +159,89 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test]
async fn list_apps_uses_external_chatgpt_auth() -> Result<()> {
let access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
.email("external@example.com")
.plan_type("pro")
.chatgpt_account_id("account-123"),
)?;
let connectors = vec![AppInfo {
id: "beta".to_string(),
name: "Beta".to_string(),
description: Some("Beta connector".to_string()),
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle, _) = start_apps_server_with_delays_and_control_inner(
connectors,
tools,
Duration::ZERO,
Duration::ZERO,
/*workspace_plugins_enabled*/ true,
&access_token,
)
.await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let login_id = mcp
.send_chatgpt_auth_tokens_login_request(
access_token,
"account-123".to_string(),
Some("pro".to_string()),
)
.await?;
let login_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(login_id)),
)
.await??;
assert_eq!(
to_response::<LoginAccountResponse>(login_response)?,
LoginAccountResponse::ChatgptAuthTokens {}
);
let request_id = mcp
.send_apps_list_request(AppsListParams {
limit: None,
cursor: None,
thread_id: None,
force_refetch: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let AppsListResponse { data, next_cursor } = to_response(response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, "beta");
assert!(data[0].is_accessible);
assert!(next_cursor.is_none());
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> { async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> {
let connectors = vec![AppInfo { let connectors = vec![AppInfo {
@@ -1557,6 +1643,7 @@ async fn start_apps_server_with_workspace_plugins_enabled(
Duration::ZERO, Duration::ZERO,
Duration::ZERO, Duration::ZERO,
workspace_plugins_enabled, workspace_plugins_enabled,
"chatgpt-token",
) )
.await?; .await?;
Ok((server_url, server_handle)) Ok((server_url, server_handle))
@@ -1574,6 +1661,7 @@ async fn start_apps_server_with_delays_and_control(
directory_delay, directory_delay,
tools_delay, tools_delay,
/*workspace_plugins_enabled*/ true, /*workspace_plugins_enabled*/ true,
"chatgpt-token",
) )
.await .await
} }
@@ -1584,13 +1672,14 @@ async fn start_apps_server_with_delays_and_control_inner(
directory_delay: Duration, directory_delay: Duration,
tools_delay: Duration, tools_delay: Duration,
workspace_plugins_enabled: bool, workspace_plugins_enabled: bool,
expected_bearer: &str,
) -> Result<(String, JoinHandle<()>, AppsServerControl)> { ) -> Result<(String, JoinHandle<()>, AppsServerControl)> {
let response = Arc::new(StdMutex::new( let response = Arc::new(StdMutex::new(
json!({ "apps": connectors, "next_token": null }), json!({ "apps": connectors, "next_token": null }),
)); ));
let tools = Arc::new(StdMutex::new(tools)); let tools = Arc::new(StdMutex::new(tools));
let state = AppsServerState { let state = AppsServerState {
expected_bearer: "Bearer chatgpt-token".to_string(), expected_bearer: format!("Bearer {expected_bearer}"),
expected_account_id: "account-123".to_string(), expected_account_id: "account-123".to_string(),
response: response.clone(), response: response.clone(),
directory_delay, directory_delay,
+80 -67
View File
@@ -19,11 +19,14 @@ use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind; use codex_login::AuthKeyringBackendKind;
use codex_login::auth::AgentIdentityAuth; use codex_login::auth::AgentIdentityAuth;
use codex_login::auth::AgentIdentityAuthRecord; use codex_login::auth::AgentIdentityAuthRecord;
use codex_login::auth::ExternalAuth;
use codex_login::auth::ExternalAuthRefreshContext;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use serde_json::json; use serde_json::json;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::future::pending; use std::future::pending;
use std::path::Path; use std::path::Path;
use std::sync::RwLock;
use std::sync::atomic::AtomicUsize; use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use tempfile::tempdir; use tempfile::tempdir;
@@ -142,26 +145,20 @@ fn chatgpt_auth_json_with_last_refresh(
refresh_token: &str, refresh_token: &str,
last_refresh: &str, last_refresh: &str,
) -> serde_json::Value { ) -> serde_json::Value {
chatgpt_auth_json_with_mode( let fake_jwt = fake_chatgpt_jwt(plan_type, chatgpt_user_id, b"sig");
plan_type, json!({
chatgpt_user_id, "OPENAI_API_KEY": null,
account_id, "tokens": {
access_token, "id_token": fake_jwt,
refresh_token, "access_token": access_token,
last_refresh, "refresh_token": refresh_token,
/*auth_mode*/ None, "account_id": account_id,
) },
"last_refresh": last_refresh,
})
} }
fn chatgpt_auth_json_with_mode( fn fake_chatgpt_jwt(plan_type: &str, chatgpt_user_id: Option<&str>, signature: &[u8]) -> String {
plan_type: &str,
chatgpt_user_id: Option<&str>,
account_id: Option<&str>,
access_token: &str,
refresh_token: &str,
last_refresh: &str,
auth_mode: Option<&str>,
) -> serde_json::Value {
let header = json!({ "alg": "none", "typ": "JWT" }); let header = json!({ "alg": "none", "typ": "JWT" });
let auth_payload = json!({ let auth_payload = json!({
"chatgpt_plan_type": plan_type, "chatgpt_plan_type": plan_type,
@@ -174,23 +171,8 @@ fn chatgpt_auth_json_with_mode(
}); });
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header"));
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload"));
let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); let signature_b64 = URL_SAFE_NO_PAD.encode(signature);
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); format!("{header_b64}.{payload_b64}.{signature_b64}")
let mut auth_json = json!({
"OPENAI_API_KEY": null,
"tokens": {
"id_token": fake_jwt,
"access_token": access_token,
"refresh_token": refresh_token,
"account_id": account_id,
},
"last_refresh": last_refresh,
});
if let Some(auth_mode) = auth_mode {
auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string());
}
auth_json
} }
fn test_bundle() -> CloudConfigBundle { fn test_bundle() -> CloudConfigBundle {
@@ -319,6 +301,39 @@ struct UnauthorizedBundleClient {
request_count: AtomicUsize, request_count: AtomicUsize,
} }
struct TestExternalChatgptAuth {
current: RwLock<CodexAuth>,
refreshed: CodexAuth,
refresh_count: AtomicUsize,
}
impl ExternalAuth for TestExternalChatgptAuth {
fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async {
self.current
.read()
.map(|auth| auth.clone())
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))
})
}
fn refresh(
&self,
_context: ExternalAuthRefreshContext,
) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async {
let refreshed = self.refreshed.clone();
*self
.current
.write()
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))? =
refreshed.clone();
self.refresh_count.fetch_add(1, Ordering::SeqCst);
Ok(refreshed)
})
}
}
impl BundleClient for UnauthorizedBundleClient { impl BundleClient for UnauthorizedBundleClient {
async fn get_bundle(&self, _auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> { async fn get_bundle(&self, _auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
self.request_count.fetch_add(1, Ordering::SeqCst); self.request_count.fetch_add(1, Ordering::SeqCst);
@@ -855,26 +870,13 @@ async fn get_bundle_surfaces_auth_recovery_message() {
} }
#[tokio::test] #[tokio::test]
async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { async fn get_bundle_refreshes_external_auth_after_unauthorized() {
let auth_home = tempdir().expect("tempdir"); let auth_home = tempdir().expect("tempdir");
write_auth_json(
auth_home.path(),
chatgpt_auth_json_with_mode(
"enterprise",
Some("user-12345"),
Some("account-12345"),
"test-access-token",
"test-refresh-token",
"2025-01-01T00:00:00Z",
Some("chatgptAuthTokens"),
),
)
.expect("write auth");
let auth_manager = Arc::new( let auth_manager = Arc::new(
AuthManager::new( AuthManager::new(
auth_home.path().to_path_buf(), auth_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false, /*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File, AuthCredentialsStoreMode::Ephemeral,
/*forced_chatgpt_workspace_id*/ None, /*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None, /*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(), AuthKeyringBackendKind::default(),
@@ -882,11 +884,32 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() {
) )
.await, .await,
); );
let initial_auth = CodexAuth::from_external_chatgpt_tokens(
&fake_chatgpt_jwt("enterprise", Some("user-12345"), b"initial"),
"account-12345",
Some("enterprise"),
)
.expect("initial external auth");
let refreshed_token = fake_chatgpt_jwt("enterprise", Some("user-12345"), b"refreshed");
let refreshed_auth = CodexAuth::from_external_chatgpt_tokens(
&refreshed_token,
"account-12345",
Some("enterprise"),
)
.expect("refreshed external auth");
let external_auth = Arc::new(TestExternalChatgptAuth {
current: RwLock::new(initial_auth),
refreshed: refreshed_auth,
refresh_count: AtomicUsize::new(0),
});
auth_manager
.set_external_auth(external_auth.clone())
.await
.expect("set external auth");
let fetcher = Arc::new(UnauthorizedBundleClient { let fetcher = Arc::new(TokenBundleClient {
message: expected_token: refreshed_token,
"GET https://chatgpt.com/backend-api/wham/config/bundle failed: 401; content-type=text/html; body=<html>nope</html>" bundle: test_bundle(),
.to_string(),
request_count: AtomicUsize::new(0), request_count: AtomicUsize::new(0),
}); });
let codex_home = tempdir().expect("tempdir"); let codex_home = tempdir().expect("tempdir");
@@ -897,19 +920,9 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() {
CLOUD_CONFIG_BUNDLE_TIMEOUT, CLOUD_CONFIG_BUNDLE_TIMEOUT,
); );
let err = service assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle())));
.load_startup_bundle() assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2);
.await assert_eq!(external_auth.refresh_count.load(Ordering::SeqCst), 1);
.expect_err("cloud config bundle should fail closed");
assert_eq!(
err,
CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::Auth,
Some(401),
CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
)
);
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
} }
#[tokio::test] #[tokio::test]
+1 -1
View File
@@ -1069,11 +1069,11 @@ async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() {
let mut auth_config = script.auth_config(); let mut auth_config = script.auth_config();
auth_config.refresh_interval_ms = 0; auth_config.refresh_interval_ms = 0;
let manager = AuthManager::external_bearer_only(auth_config); let manager = AuthManager::external_bearer_only(auth_config);
let mut recovery = manager.unauthorized_recovery();
let initial_token = manager let initial_token = manager
.auth() .auth()
.await .await
.and_then(|auth| auth.api_key().map(str::to_string)); .and_then(|auth| auth.api_key().map(str::to_string));
let mut recovery = manager.unauthorized_recovery();
assert!(recovery.has_next()); assert!(recovery.has_next());
assert_eq!(recovery.mode_name(), "external"); assert_eq!(recovery.mode_name(), "external");
+4 -11
View File
@@ -2,7 +2,6 @@ use super::manager::CodexAuth;
use super::manager::ExternalAuth; use super::manager::ExternalAuth;
use super::manager::ExternalAuthFuture; use super::manager::ExternalAuthFuture;
use super::manager::ExternalAuthRefreshContext; use super::manager::ExternalAuthRefreshContext;
use codex_protocol::auth::AuthMode;
use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::config_types::ModelProviderAuthInfo;
use std::fmt; use std::fmt;
use std::io; use std::io;
@@ -30,7 +29,7 @@ impl BearerTokenRefresher {
clippy::await_holding_invalid_type, clippy::await_holding_invalid_type,
reason = "external bearer cache misses intentionally hold cached_token across the provider command to avoid duplicate refreshes" reason = "external bearer cache misses intentionally hold cached_token across the provider command to avoid duplicate refreshes"
)] )]
async fn resolve(&self) -> io::Result<Option<CodexAuth>> { async fn resolve(&self) -> io::Result<CodexAuth> {
let access_token = { let access_token = {
let mut cached = self.state.cached_token.lock().await; let mut cached = self.state.cached_token.lock().await;
if let Some(cached_token) = cached.as_ref() { if let Some(cached_token) = cached.as_ref() {
@@ -39,9 +38,7 @@ impl BearerTokenRefresher {
None => true, None => true,
}; };
if should_use_cached_token { if should_use_cached_token {
return Ok(Some(CodexAuth::from_api_key( return Ok(CodexAuth::from_api_key(cached_token.access_token.as_str()));
cached_token.access_token.as_str(),
)));
} }
} }
@@ -52,7 +49,7 @@ impl BearerTokenRefresher {
}); });
access_token access_token
}; };
Ok(Some(CodexAuth::from_api_key(access_token.as_str()))) Ok(CodexAuth::from_api_key(access_token.as_str()))
} }
async fn refresh(&self, _context: ExternalAuthRefreshContext) -> io::Result<CodexAuth> { async fn refresh(&self, _context: ExternalAuthRefreshContext) -> io::Result<CodexAuth> {
@@ -67,11 +64,7 @@ impl BearerTokenRefresher {
} }
impl ExternalAuth for BearerTokenRefresher { impl ExternalAuth for BearerTokenRefresher {
fn auth_mode(&self) -> AuthMode { fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
AuthMode::ApiKey
}
fn resolve(&self) -> ExternalAuthFuture<'_, Option<CodexAuth>> {
Box::pin(BearerTokenRefresher::resolve(self)) Box::pin(BearerTokenRefresher::resolve(self))
} }
+116 -130
View File
@@ -211,19 +211,12 @@ pub struct ExternalAuthRefreshContext {
/// Pluggable auth provider used by `AuthManager` for externally managed auth flows. /// Pluggable auth provider used by `AuthManager` for externally managed auth flows.
/// ///
/// Implementations may either resolve auth eagerly via `resolve()` or provide refreshed /// Implementations own the current auth value and any source-specific refresh mechanism.
/// auth on demand via `refresh()`.
pub trait ExternalAuth: Send + Sync { pub trait ExternalAuth: Send + Sync {
/// Indicates which top-level auth mode this external provider supplies. /// Returns the provider's current auth value.
fn auth_mode(&self) -> AuthMode; fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth>;
/// Returns cached or immediately available auth, if this provider can resolve it synchronously /// Refreshes auth and makes the returned value current for future `resolve()` calls.
/// from the caller's perspective.
fn resolve(&self) -> ExternalAuthFuture<'_, Option<CodexAuth>> {
Box::pin(async { Ok(None) })
}
/// Refreshes auth in response to a manager-driven refresh attempt.
fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth>; fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth>;
} }
@@ -1555,14 +1548,9 @@ enum UnauthorizedRecoveryMode {
// 2. Attempt to refresh the token using OAuth token refresh flow. // 2. Attempt to refresh the token using OAuth token refresh flow.
// If after both steps the server still responds with 401 we let the error bubble to the user. // If after both steps the server still responds with 401 we let the error bubble to the user.
// //
// For external auth sources, UnauthorizedRecovery retries once. // For external auth sources, UnauthorizedRecovery retries once by asking the
// // configured provider to refresh and caching the returned auth through the same
// - External ChatGPT auth tokens (`chatgptAuthTokens`) are refreshed by asking // path used by other auth sources.
// the parent app for new tokens through the configured
// `ExternalAuth`, persisting them in the ephemeral auth store, and
// reloading the cached auth snapshot.
// - External bearer auth sources for custom model providers rerun the provider
// auth command without touching disk.
pub struct UnauthorizedRecovery { pub struct UnauthorizedRecovery {
manager: Arc<AuthManager>, manager: Arc<AuthManager>,
step: UnauthorizedRecoveryStep, step: UnauthorizedRecoveryStep,
@@ -1585,11 +1573,7 @@ impl UnauthorizedRecovery {
fn new(manager: Arc<AuthManager>) -> Self { fn new(manager: Arc<AuthManager>) -> Self {
let cached_auth = manager.auth_cached(); let cached_auth = manager.auth_cached();
let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id); let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id);
let mode = if manager.has_external_api_key_auth() let mode = if manager.has_external_auth() {
|| cached_auth
.as_ref()
.is_some_and(CodexAuth::is_external_chatgpt_tokens)
{
UnauthorizedRecoveryMode::External UnauthorizedRecoveryMode::External
} else { } else {
UnauthorizedRecoveryMode::Managed UnauthorizedRecoveryMode::Managed
@@ -1725,9 +1709,7 @@ impl UnauthorizedRecovery {
}); });
} }
UnauthorizedRecoveryStep::ExternalRefresh => { UnauthorizedRecoveryStep::ExternalRefresh => {
self.manager self.manager.refresh_token_from_authority().await?;
.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized)
.await?;
self.step = UnauthorizedRecoveryStep::Done; self.step = UnauthorizedRecoveryStep::Done;
return Ok(UnauthorizedRecoveryStepResult { return Ok(UnauthorizedRecoveryStepResult {
auth_state_changed: Some(true), auth_state_changed: Some(true),
@@ -1980,7 +1962,10 @@ impl AuthManager {
/// Current cached auth (clone) without attempting a refresh. /// Current cached auth (clone) without attempting a refresh.
pub fn auth_cached(&self) -> Option<CodexAuth> { pub fn auth_cached(&self) -> Option<CodexAuth> {
self.inner.read().ok().and_then(|c| c.auth.clone()) self.inner
.read()
.ok()
.and_then(|cached| cached.auth.clone())
} }
/// Subscribes to cached auth changes that can affect request recovery. /// Subscribes to cached auth changes that can affect request recovery.
@@ -2003,8 +1988,9 @@ impl AuthManager {
/// a guarded reload and then refreshes only if the on-disk auth is unchanged. /// a guarded reload and then refreshes only if the on-disk auth is unchanged.
#[instrument(level = "trace", skip_all)] #[instrument(level = "trace", skip_all)]
pub async fn auth(&self) -> Option<CodexAuth> { pub async fn auth(&self) -> Option<CodexAuth> {
if let Some(auth) = self.resolve_external_api_key_auth().await { if self.has_external_auth() {
return Some(auth); self.reload().await;
return self.auth_cached();
} }
let auth = self.auth_cached()?; let auth = self.auth_cached()?;
@@ -2080,11 +2066,10 @@ impl AuthManager {
.await .await
} }
/// Force a reload of the auth information from auth.json. Returns /// Reloads auth from the active source. Returns whether the auth value changed.
/// whether the auth value changed.
pub async fn reload(&self) -> bool { pub async fn reload(&self) -> bool {
tracing::info!("Reloading auth"); tracing::info!("Reloading auth");
let new_auth = self.load_auth_from_storage().await; let new_auth = self.load_auth().await;
self.set_cached_auth(new_auth) self.set_cached_auth(new_auth)
} }
@@ -2100,7 +2085,7 @@ impl AuthManager {
} }
}; };
let new_auth = self.load_auth_from_storage().await; let new_auth = self.load_auth().await;
let new_account_id = new_auth.as_ref().and_then(CodexAuth::get_account_id); let new_account_id = new_auth.as_ref().and_then(CodexAuth::get_account_id);
if new_account_id.as_deref() != Some(expected_account_id) { if new_account_id.as_deref() != Some(expected_account_id) {
@@ -2173,7 +2158,17 @@ impl AuthManager {
} }
} }
async fn load_auth_from_storage(&self) -> Option<CodexAuth> { async fn load_auth(&self) -> Option<CodexAuth> {
if let Some(external_auth) = self.external_auth() {
return match self.resolve_external_auth(&external_auth).await {
Ok(auth) => Some(auth),
Err(err) => {
tracing::error!("Failed to resolve external auth: {err}");
None
}
};
}
let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id();
load_auth( load_auth(
&self.codex_home, &self.codex_home,
@@ -2210,15 +2205,22 @@ impl AuthManager {
} }
} }
pub fn set_external_auth(&self, external_auth: Arc<dyn ExternalAuth>) { pub async fn set_external_auth(
if let Ok(mut guard) = self.external_auth.write() { &self,
*guard = Some(external_auth); external_auth: Arc<dyn ExternalAuth>,
} ) -> Result<(), RefreshTokenError> {
let auth = self.resolve_external_auth(&external_auth).await?;
*self.external_auth.write().map_err(|_| {
RefreshTokenError::Transient(std::io::Error::other("external auth lock is poisoned"))
})? = Some(external_auth);
self.commit_external_auth(auth)
} }
pub fn clear_external_auth(&self) { pub fn clear_external_auth(&self) {
if let Ok(mut guard) = self.external_auth.write() { if let Ok(mut external_auth) = self.external_auth.write()
*guard = None; && external_auth.take().is_some()
{
self.set_cached_auth(/*new_auth*/ None);
} }
} }
@@ -2300,48 +2302,33 @@ impl AuthManager {
self.external_auth self.external_auth
.read() .read()
.ok() .ok()
.and_then(|guard| guard.as_ref().cloned()) .and_then(|external_auth| external_auth.as_ref().map(Arc::clone))
}
fn external_auth_mode(&self) -> Option<AuthMode> {
self.external_auth()
.as_ref()
.map(|external_auth| external_auth.auth_mode())
} }
fn has_external_api_key_auth(&self) -> bool { fn has_external_api_key_auth(&self) -> bool {
self.external_auth_mode() == Some(AuthMode::ApiKey) self.has_external_auth()
&& self
.auth_cached()
.as_ref()
.is_some_and(CodexAuth::is_api_key_auth)
} }
async fn resolve_external_api_key_auth(&self) -> Option<CodexAuth> { async fn resolve_external_auth(
if !self.has_external_api_key_auth() { &self,
return None; external_auth: &Arc<dyn ExternalAuth>,
} ) -> Result<CodexAuth, RefreshTokenError> {
let auth = external_auth
let external_auth = self.external_auth()?; .resolve()
.await
match external_auth.resolve().await { .map_err(RefreshTokenError::Transient)?;
Ok(Some(auth)) if auth.api_auth_mode() == AuthMode::ApiKey => Some(auth), self.validate_external_auth(&auth)?;
Ok(Some(auth)) => { Ok(auth)
tracing::error!(
resolved_mode = ?auth.api_auth_mode(),
"ignoring non-API-key auth from external API key provider"
);
None
}
Ok(None) => None,
Err(err) => {
tracing::error!("Failed to resolve external API key auth: {err}");
None
}
}
} }
/// Attempt to refresh the token by first performing a guarded reload. Auth /// Attempt to refresh the token by first performing a guarded reload from
/// is reloaded from storage only when the account id matches the currently /// the active auth source. If the loaded token differs from the cached token,
/// cached account id. If the persisted token differs from the cached token, we /// we can assume that the source already refreshed it. Otherwise, ask the
/// can assume that some other instance already refreshed it. If the persisted /// token authority to refresh.
/// token is the same as the cached, then ask the token authority to refresh.
pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> { pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> {
let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| { let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| {
RefreshTokenError::Permanent(RefreshTokenFailedError::new( RefreshTokenError::Permanent(RefreshTokenFailedError::new(
@@ -2379,9 +2366,8 @@ impl AuthManager {
} }
/// Attempt to refresh the current auth token from the authority that issued /// Attempt to refresh the current auth token from the authority that issued
/// the token. On success, reloads the auth state from disk so other components /// it and update the shared cache. If the token refresh fails, returns the
/// observe refreshed token. If the token refresh fails, returns the error to /// error to the caller.
/// the caller.
pub async fn refresh_token_from_authority(&self) -> Result<(), RefreshTokenError> { pub async fn refresh_token_from_authority(&self) -> Result<(), RefreshTokenError> {
let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| { let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| {
RefreshTokenError::Permanent(RefreshTokenFailedError::new( RefreshTokenError::Permanent(RefreshTokenFailedError::new(
@@ -2404,24 +2390,26 @@ impl AuthManager {
} }
let attempted_auth = auth.clone(); let attempted_auth = auth.clone();
let result = match auth { let result = if self.has_external_auth() {
CodexAuth::ChatgptAuthTokens(_) => { self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized)
self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) .await
.await } else {
match auth {
CodexAuth::Chatgpt(chatgpt_auth) => {
let token_data = chatgpt_auth.current_token_data().ok_or_else(|| {
RefreshTokenError::Transient(std::io::Error::other(
"Token data is not available.",
))
})?;
self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token)
.await
}
CodexAuth::ApiKey(_)
| CodexAuth::ChatgptAuthTokens(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_)
| CodexAuth::BedrockApiKey(_) => Ok(()),
} }
CodexAuth::Chatgpt(chatgpt_auth) => {
let token_data = chatgpt_auth.current_token_data().ok_or_else(|| {
RefreshTokenError::Transient(std::io::Error::other(
"Token data is not available.",
))
})?;
self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token)
.await
}
CodexAuth::ApiKey(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_)
| CodexAuth::BedrockApiKey(_) => Ok(()),
}; };
if let Err(RefreshTokenError::Permanent(error)) = &result { if let Err(RefreshTokenError::Permanent(error)) = &result {
self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error);
@@ -2440,6 +2428,7 @@ impl AuthManager {
self.keyring_backend_kind, self.keyring_backend_kind,
)?; )?;
// Always reload to clear any cached auth (even if file absent). // Always reload to clear any cached auth (even if file absent).
self.clear_external_auth();
self.reload().await; self.reload().await;
Ok(removed) Ok(removed)
} }
@@ -2459,23 +2448,18 @@ impl AuthManager {
self.keyring_backend_kind, self.keyring_backend_kind,
)?; )?;
// Always reload to clear any cached auth (even if file absent). // Always reload to clear any cached auth (even if file absent).
self.clear_external_auth();
self.reload().await; self.reload().await;
Ok(result) Ok(result)
} }
/// Returns the precise kind of credentials backing the current authentication. /// Returns the precise kind of credentials backing the current authentication.
pub fn get_api_auth_mode(&self) -> Option<AuthMode> { pub fn get_api_auth_mode(&self) -> Option<AuthMode> {
if self.has_external_api_key_auth() {
return Some(AuthMode::ApiKey);
}
self.auth_cached().as_ref().map(CodexAuth::api_auth_mode) self.auth_cached().as_ref().map(CodexAuth::api_auth_mode)
} }
/// Returns the effective backend auth mode for the current authentication. /// Returns the effective backend auth mode for the current authentication.
pub fn auth_mode(&self) -> Option<AuthMode> { pub fn auth_mode(&self) -> Option<AuthMode> {
if self.has_external_api_key_auth() {
return Some(AuthMode::ApiKey);
}
self.auth_cached().as_ref().map(CodexAuth::auth_mode) self.auth_cached().as_ref().map(CodexAuth::auth_mode)
} }
@@ -2517,7 +2501,6 @@ impl AuthManager {
"external auth is not configured", "external auth is not configured",
))); )));
}; };
let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id();
let previous_account_id = self let previous_account_id = self
.auth_cached() .auth_cached()
.as_ref() .as_ref()
@@ -2531,41 +2514,44 @@ impl AuthManager {
.refresh(context) .refresh(context)
.await .await
.map_err(RefreshTokenError::Transient)?; .map_err(RefreshTokenError::Transient)?;
if external_auth.auth_mode() == AuthMode::ApiKey { self.validate_external_auth(&refreshed)?;
return Ok(()); self.commit_external_auth(refreshed)?;
Ok(())
}
fn commit_external_auth(&self, auth: CodexAuth) -> Result<(), RefreshTokenError> {
if auth.is_external_chatgpt_tokens() {
let auth_dot_json = auth.get_current_auth_json().ok_or_else(|| {
RefreshTokenError::Transient(std::io::Error::other(
"external ChatGPT auth tokens are missing auth state",
))
})?;
// App/connectors paths still construct independent AuthManagers from Config. Mirror
// external ChatGPT auth into the process-local store so those managers see it too.
save_auth(
&self.codex_home,
&auth_dot_json,
AuthCredentialsStoreMode::Ephemeral,
AuthKeyringBackendKind::default(),
)
.map_err(RefreshTokenError::Transient)?;
} }
if refreshed.api_auth_mode() != AuthMode::ChatgptAuthTokens {
return Err(RefreshTokenError::Transient(std::io::Error::other( self.set_cached_auth(Some(auth));
"external auth refresh did not return ChatGPT auth tokens", Ok(())
))); }
}
let account_id = refreshed.get_account_id().ok_or_else(|| { fn validate_external_auth(&self, auth: &CodexAuth) -> Result<(), RefreshTokenError> {
RefreshTokenError::Transient(std::io::Error::other( if let Some(account_id) = auth.get_account_id()
"external ChatGPT auth tokens are missing an account id", && let Some(expected_workspace_ids) = self.forced_chatgpt_workspace_id()
))
})?;
if let Some(expected_workspace_ids) = forced_chatgpt_workspace_id.as_deref()
&& !expected_workspace_ids.contains(&account_id) && !expected_workspace_ids.contains(&account_id)
{ {
return Err(RefreshTokenError::Transient(std::io::Error::other( return Err(RefreshTokenError::Transient(std::io::Error::other(
format!( format!(
"external auth refresh returned workspace {account_id:?}, expected one of {expected_workspace_ids:?}" "external auth returned workspace {account_id:?}, expected one of {expected_workspace_ids:?}"
), ),
))); )));
} }
let auth_dot_json = refreshed.get_current_auth_json().ok_or_else(|| {
RefreshTokenError::Transient(std::io::Error::other(
"external ChatGPT auth tokens are missing auth state",
))
})?;
save_auth(
&self.codex_home,
&auth_dot_json,
AuthCredentialsStoreMode::Ephemeral,
AuthKeyringBackendKind::default(),
)
.map_err(RefreshTokenError::Transient)?;
self.reload().await;
Ok(()) Ok(())
} }
+16 -11
View File
@@ -117,12 +117,8 @@ impl TestModelsEndpoint {
struct TestExternalApiKeyAuth; struct TestExternalApiKeyAuth;
impl ExternalAuth for TestExternalApiKeyAuth { impl ExternalAuth for TestExternalApiKeyAuth {
fn auth_mode(&self) -> AuthMode { fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
AuthMode::ApiKey Box::pin(async { Ok(CodexAuth::from_api_key("test-external-api-key")) })
}
fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, Option<CodexAuth>> {
Box::pin(async { Ok(Some(CodexAuth::from_api_key("test-external-api-key"))) })
} }
fn refresh( fn refresh(
@@ -137,8 +133,8 @@ impl ExternalAuth for TestExternalApiKeyAuth {
struct TestUnresolvedExternalApiKeyAuth; struct TestUnresolvedExternalApiKeyAuth;
impl ExternalAuth for TestUnresolvedExternalApiKeyAuth { impl ExternalAuth for TestUnresolvedExternalApiKeyAuth {
fn auth_mode(&self) -> AuthMode { fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
AuthMode::ApiKey Box::pin(async { Err(std::io::Error::other("unresolved test auth")) })
} }
fn refresh( fn refresh(
@@ -858,7 +854,10 @@ async fn refresh_available_models_skips_network_when_external_api_key_overrides_
let codex_home = tempdir().expect("temp dir"); let codex_home = tempdir().expect("temp dir");
let auth_manager = let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
auth_manager.set_external_auth(Arc::new(TestExternalApiKeyAuth)); auth_manager
.set_external_auth(Arc::new(TestExternalApiKeyAuth))
.await
.expect("external API key auth should resolve");
let endpoint = TestAuthAwareModelsEndpoint::new( let endpoint = TestAuthAwareModelsEndpoint::new(
Some(Arc::clone(&auth_manager)), Some(Arc::clone(&auth_manager)),
vec![vec![remote_model( vec![vec![remote_model(
@@ -898,7 +897,10 @@ async fn refresh_available_models_uses_cached_chatgpt_when_external_api_key_is_u
let codex_home = tempdir().expect("temp dir"); let codex_home = tempdir().expect("temp dir");
let auth_manager = let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
auth_manager.set_external_auth(Arc::new(TestUnresolvedExternalApiKeyAuth)); auth_manager
.set_external_auth(Arc::new(TestUnresolvedExternalApiKeyAuth))
.await
.expect_err("unresolved external auth should be rejected");
let endpoint = TestAuthAwareModelsEndpoint::new( let endpoint = TestAuthAwareModelsEndpoint::new(
Some(Arc::clone(&auth_manager)), Some(Arc::clone(&auth_manager)),
vec![vec![remote_model( vec![vec![remote_model(
@@ -1013,7 +1015,10 @@ async fn static_manager_reads_latest_auth_mode() {
vec!["chatgpt-only", "api-model"] vec!["chatgpt-only", "api-model"]
); );
auth_manager.set_external_auth(Arc::new(TestExternalApiKeyAuth)); auth_manager
.set_external_auth(Arc::new(TestExternalApiKeyAuth))
.await
.expect("external API key auth should resolve");
let api_models = manager.list_models(RefreshStrategy::Online).await; let api_models = manager.list_models(RefreshStrategy::Online).await;
assert_eq!( assert_eq!(