mirror of
https://github.com/openai/codex.git
synced 2026-10-21 05:22:21 +00:00
[connectors] Refresh codex_apps /ps/mcp auth (#31486)
[Codex Thread 019f2408-dc59-79f2-b245-4c11debd1a61](https://codex-thread-link.openai.chatgpt-team.site/thread/019f2408-dc59-79f2-b245-4c11debd1a61) ## Why Long-lived Codex sessions can outlive the ChatGPT bearer token that was present when the MCP runtime started. The Responses path already recovers from token expiration by refreshing or reloading the shared `AuthManager`. The reserved `codex_apps` hosted-plugin client did not observe that update: `McpConnectionManager` built its `/ps/mcp` HTTP auth once from a `CodexAuth` snapshot, and `auth_provider_from_auth` copied that snapshot bearer into a static `BearerAuthProvider`. After the copied bearer expired, `/ps/mcp` kept sending it even though Responses had a newer token in the same `AuthManager`. The failure occurred before downstream connector execution, so unrelated apps such as Gmail, Slack, and Google Calendar could all fail with the same transport-level `401 token_expired`. This replaces [openai/codex#29474](https://github.com/openai/codex/pull/29474), which was closed for inactivity without being merged. A new long-lived-session report reproduced the same simultaneous `/ps/mcp` expiry pattern across unrelated apps. ## What changed - Add an `AuthManager`-backed request-header provider in `codex-model-provider`. It keeps an `Arc<AuthManager>` and reads `auth_cached()` for each outbound request, so the next `/ps/mcp` call sees a token refreshed by the existing Responses/auth-recovery flow. - Scope that provider to the startup account, ChatGPT user, and workspace identity. Same-identity token reloads are followed; an account switch emits no ambient auth until account-scoped MCP state is rebuilt. - Have `McpConnectionManager` construct the dynamic provider only for the reserved `codex_apps` registration used by the hosted-plugin `/ps/mcp` path. | MCP path | Auth behavior after this change | | --- | --- | | Reserved `codex_apps` hosted-plugin `/ps/mcp` | Read current same-identity auth from the shared `AuthManager` per request | | `codex_apps` with `CODEX_CONNECTORS_TOKEN` | Keep the environment bearer-token override | | User-configured/direct MCP registrations | Keep their existing configured auth path | ## Non-goals - No plugin-service changes. - No downstream Slack, Gmail, Calendar, or other connector OAuth/link-refresh changes. - No auth UI changes. - No behavior change for user-configured/direct MCP registrations. - No new `/ps/mcp`-initiated token refresh; this makes `/ps/mcp` observe refreshes already performed through the shared `AuthManager`. ## Tests - `just test -p codex-model-provider` - Covers same-identity token reloads and refuses a changed startup identity. - `just test -p codex-mcp` - `just test -p codex-core mcp_auth_refresh` - Creates the reserved hosted-plugin `codex_apps` `/ps/mcp` client before the shared `AuthManager` changes, updates that same manager through its public external-auth path, performs a real `tools/call`, and asserts the request uses the current bearer.
This commit is contained in:
@@ -47,6 +47,7 @@ use codex_config::McpServerAuth;
|
||||
use codex_config::McpServerTransportConfig;
|
||||
use codex_config::types::AuthKeyringBackendKind;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::mcp::McpServerInfo;
|
||||
@@ -141,6 +142,7 @@ impl McpConnectionManager {
|
||||
supports_openai_form_elicitation: bool,
|
||||
tool_plugin_provenance: ToolPluginProvenance,
|
||||
auth: Option<&CodexAuth>,
|
||||
codex_apps_auth_manager: Option<Arc<AuthManager>>,
|
||||
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
elicitation_lifecycle: Option<crate::ElicitationLifecycle>,
|
||||
elicitation_router: ElicitationRequestRouter,
|
||||
@@ -163,9 +165,14 @@ impl McpConnectionManager {
|
||||
);
|
||||
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
|
||||
let startup_submit_id = submit_id.clone();
|
||||
let chatgpt_auth_provider = auth
|
||||
let static_chatgpt_auth_provider = auth
|
||||
.filter(|auth| auth.uses_codex_backend())
|
||||
.map(codex_model_provider::auth_provider_from_auth);
|
||||
let codex_apps_auth_provider = codex_apps_auth_manager.and_then(|auth_manager| {
|
||||
auth.filter(|auth| auth.uses_codex_backend()).map(|auth| {
|
||||
codex_model_provider::auth_provider_from_auth_manager(auth_manager, auth)
|
||||
})
|
||||
});
|
||||
let mcp_servers = mcp_servers.clone();
|
||||
for (server_name, server) in mcp_servers
|
||||
.into_iter()
|
||||
@@ -200,13 +207,24 @@ impl McpConnectionManager {
|
||||
codex_apps_tools_cache
|
||||
.context(codex_home.clone(), codex_apps_tools_cache_key.clone())
|
||||
});
|
||||
// The reserved Codex Apps registration follows the shared
|
||||
// AuthManager across refreshes. In the hosted-plugin path, this
|
||||
// is the ChatGPT /ps/mcp connection. User-configured MCP
|
||||
// registrations keep their existing configured auth path.
|
||||
let chatgpt_auth_provider = if server_name == CODEX_APPS_MCP_SERVER_NAME {
|
||||
codex_apps_auth_provider
|
||||
.clone()
|
||||
.or_else(|| static_chatgpt_auth_provider.clone())
|
||||
} else {
|
||||
static_chatgpt_auth_provider.clone()
|
||||
};
|
||||
// If Codex Apps has an env bearer token, that is its auth path. Do
|
||||
// not also attach the ambient CodexAuth provider.
|
||||
let runtime_auth_provider =
|
||||
if server_name == CODEX_APPS_MCP_SERVER_NAME && uses_env_bearer_token {
|
||||
None
|
||||
} else {
|
||||
chatgpt_auth_provider_for_server(&server, chatgpt_auth_provider.clone())
|
||||
chatgpt_auth_provider_for_server(&server, chatgpt_auth_provider)
|
||||
};
|
||||
let async_managed_client = AsyncManagedClient::new(
|
||||
server_name.clone(),
|
||||
|
||||
@@ -1561,6 +1561,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
ToolPluginProvenance::default(),
|
||||
/*auth*/ None,
|
||||
/*codex_apps_auth_manager*/ None,
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
ElicitationRequestRouter::default(),
|
||||
|
||||
@@ -338,6 +338,7 @@ pub async fn read_mcp_resource(
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
tool_plugin_provenance(config),
|
||||
auth,
|
||||
/*codex_apps_auth_manager*/ None,
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
crate::elicitation::ElicitationRequestRouter::default(),
|
||||
@@ -416,6 +417,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
tool_plugin_provenance,
|
||||
auth,
|
||||
/*codex_apps_auth_manager*/ None,
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
crate::elicitation::ElicitationRequestRouter::default(),
|
||||
|
||||
@@ -248,6 +248,9 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
drop(rx_event);
|
||||
|
||||
let cancel_token = CancellationToken::new();
|
||||
let codex_apps_auth_manager =
|
||||
codex_mcp::host_owned_codex_apps_enabled(&mcp_config, auth.as_ref())
|
||||
.then(|| Arc::clone(&auth_manager));
|
||||
let mcp_connection_manager = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
@@ -269,6 +272,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
ToolPluginProvenance::default(),
|
||||
auth.as_ref(),
|
||||
codex_apps_auth_manager,
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
codex_mcp::ElicitationRequestRouter::default(),
|
||||
|
||||
@@ -1462,6 +1462,7 @@ async fn host_owned_codex_apps_manager(
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
codex_mcp::ToolPluginProvenance::default(),
|
||||
auth.as_ref(),
|
||||
/*codex_apps_auth_manager*/ None,
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
codex_mcp::ElicitationRequestRouter::default(),
|
||||
|
||||
@@ -361,6 +361,9 @@ impl Session {
|
||||
cancellation_token
|
||||
};
|
||||
let current_runtime = self.services.latest_mcp_runtime();
|
||||
let codex_apps_auth_manager =
|
||||
codex_mcp::host_owned_codex_apps_enabled(&mcp_config, auth.as_ref())
|
||||
.then(|| Arc::clone(&self.services.auth_manager));
|
||||
let refreshed_manager = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
mcp_config.mcp_oauth_credentials_store_mode,
|
||||
@@ -382,6 +385,7 @@ impl Session {
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
tool_plugin_provenance,
|
||||
auth.as_ref(),
|
||||
codex_apps_auth_manager,
|
||||
elicitation_reviewer,
|
||||
Some(self.mcp_elicitation_lifecycle()),
|
||||
current_runtime.manager().elicitation_router(),
|
||||
|
||||
@@ -1194,6 +1194,9 @@ impl Session {
|
||||
*cancel_guard = cancel_token.clone();
|
||||
cancel_token
|
||||
};
|
||||
let codex_apps_auth_manager =
|
||||
codex_mcp::host_owned_codex_apps_enabled(&mcp_projection.config, auth)
|
||||
.then(|| Arc::clone(&sess.services.auth_manager));
|
||||
let mcp_connection_manager = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
@@ -1218,6 +1221,7 @@ impl Session {
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
tool_plugin_provenance,
|
||||
auth,
|
||||
codex_apps_auth_manager,
|
||||
Some(sess.mcp_elicitation_reviewer()),
|
||||
Some(sess.mcp_elicitation_lifecycle()),
|
||||
codex_mcp::ElicitationRequestRouter::default(),
|
||||
|
||||
@@ -26,6 +26,8 @@ const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a
|
||||
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
|
||||
const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar.";
|
||||
const CODEX_APPS_META_KEY: &str = "_codex_apps";
|
||||
const CODEX_APPS_MCP_PATH_REGEX: &str = "^/api/codex/apps/?$";
|
||||
const HOSTED_PLUGIN_RUNTIME_MCP_PATH_REGEX: &str = "^/api/codex/ps/mcp/?$";
|
||||
const PROTOCOL_VERSION: &str = "2025-11-25";
|
||||
const SERVER_NAME: &str = "codex-apps-test";
|
||||
const SERVER_VERSION: &str = "1.0.0";
|
||||
@@ -108,6 +110,24 @@ impl AppsTestServer {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn mount_hosted_plugin_runtime_searchable(server: &MockServer) -> Result<Self> {
|
||||
mount_oauth_metadata(server).await;
|
||||
mount_connectors_directory(server).await;
|
||||
mount_streamable_http_json_rpc_at_path(
|
||||
server,
|
||||
HOSTED_PLUGIN_RUNTIME_MCP_PATH_REGEX,
|
||||
CONNECTOR_NAME.to_string(),
|
||||
CONNECTOR_DESCRIPTION.to_string(),
|
||||
/*searchable*/ true,
|
||||
/*include_app_only_tool*/ false,
|
||||
AppsTestToolsListBehavior::AlwaysAvailable,
|
||||
)
|
||||
.await;
|
||||
Ok(Self {
|
||||
chatgpt_base_url: server.uri(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn mount_with_connector_name(
|
||||
server: &MockServer,
|
||||
connector_name: &str,
|
||||
@@ -159,6 +179,7 @@ impl AppsTestServer {
|
||||
};
|
||||
mount_streamable_http_json_rpc_with_startup_control(
|
||||
server,
|
||||
CODEX_APPS_MCP_PATH_REGEX,
|
||||
CONNECTOR_NAME.to_string(),
|
||||
CONNECTOR_DESCRIPTION.to_string(),
|
||||
/*searchable*/ true,
|
||||
@@ -358,9 +379,31 @@ async fn mount_streamable_http_json_rpc(
|
||||
searchable: bool,
|
||||
include_app_only_tool: bool,
|
||||
tools_list_behavior: AppsTestToolsListBehavior,
|
||||
) {
|
||||
mount_streamable_http_json_rpc_at_path(
|
||||
server,
|
||||
CODEX_APPS_MCP_PATH_REGEX,
|
||||
connector_name,
|
||||
connector_description,
|
||||
searchable,
|
||||
include_app_only_tool,
|
||||
tools_list_behavior,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_streamable_http_json_rpc_at_path(
|
||||
server: &MockServer,
|
||||
mcp_path_regex: &str,
|
||||
connector_name: String,
|
||||
connector_description: String,
|
||||
searchable: bool,
|
||||
include_app_only_tool: bool,
|
||||
tools_list_behavior: AppsTestToolsListBehavior,
|
||||
) {
|
||||
mount_streamable_http_json_rpc_with_startup_control(
|
||||
server,
|
||||
mcp_path_regex,
|
||||
connector_name,
|
||||
connector_description,
|
||||
searchable,
|
||||
@@ -375,6 +418,7 @@ async fn mount_streamable_http_json_rpc(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn mount_streamable_http_json_rpc_with_startup_control(
|
||||
server: &MockServer,
|
||||
mcp_path_regex: &str,
|
||||
connector_name: String,
|
||||
connector_description: String,
|
||||
searchable: bool,
|
||||
@@ -384,7 +428,7 @@ async fn mount_streamable_http_json_rpc_with_startup_control(
|
||||
remaining_initialize_failures: Option<Arc<AtomicUsize>>,
|
||||
) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex("^/api/codex/apps/?$"))
|
||||
.and(path_regex(mcp_path_regex))
|
||||
.respond_with(CodexAppsJsonRpcResponder {
|
||||
connector_name,
|
||||
connector_description,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_config::McpServerTransportConfig;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_login::AuthKeyringBackendKind;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::ExternalAuth;
|
||||
use codex_login::ExternalAuthFuture;
|
||||
use codex_login::ExternalAuthRefreshContext;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::CodexAppsToolsCache;
|
||||
use codex_mcp::EffectiveMcpServer;
|
||||
use codex_mcp::ElicitationRequestRouter;
|
||||
use codex_mcp::McpConnectionManager;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use codex_mcp::ToolPluginProvenance;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::ElicitationCapability;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
// Installs a known snapshot through AuthManager's public external-auth path.
|
||||
struct StaticExternalAuth(CodexAuth);
|
||||
|
||||
impl ExternalAuth for StaticExternalAuth {
|
||||
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
|
||||
Box::pin(async { Ok(self.0.clone()) })
|
||||
}
|
||||
|
||||
fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
|
||||
Box::pin(async { Ok(self.0.clone()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_current_auth_manager_token() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount_hosted_plugin_runtime_searchable(&server).await?;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let expected_auth = CodexAuth::from_external_chatgpt_tokens(
|
||||
"header.e30.first",
|
||||
"test-account",
|
||||
/*chatgpt_plan_type*/ None,
|
||||
)?;
|
||||
let auth_manager = AuthManager::from_auth_for_testing_with_home(
|
||||
expected_auth.clone(),
|
||||
home.path().to_path_buf(),
|
||||
);
|
||||
// Build the hosted-plugin config directly so the local test origin can
|
||||
// exercise the connection-manager auth path. Effective server resolution
|
||||
// correctly strips ChatGPT auth from untrusted localhost origins.
|
||||
let mut hosted_plugin_runtime_config = codex_mcp::hosted_plugin_runtime_mcp_server_config(
|
||||
&apps_server.chatgpt_base_url,
|
||||
/*apps_mcp_product_sku*/ None,
|
||||
);
|
||||
let McpServerTransportConfig::StreamableHttp {
|
||||
bearer_token_env_var,
|
||||
..
|
||||
} = &mut hosted_plugin_runtime_config.transport
|
||||
else {
|
||||
panic!("hosted plugin runtime should use streamable HTTP");
|
||||
};
|
||||
// Keep the test on the AuthManager path even if the developer has the
|
||||
// debug bearer override in their environment.
|
||||
*bearer_token_env_var = None;
|
||||
let mcp_servers = HashMap::from([(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
EffectiveMcpServer::configured(hosted_plugin_runtime_config),
|
||||
)]);
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
drop(rx_event);
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::Never);
|
||||
let manager = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
OAuthCredentialsStoreMode::default(),
|
||||
AuthKeyringBackendKind::default(),
|
||||
HashMap::new(),
|
||||
&approval_policy,
|
||||
"test".to_string(),
|
||||
tx_event,
|
||||
CancellationToken::new(),
|
||||
PermissionProfile::default(),
|
||||
McpRuntimeContext::new(
|
||||
Arc::new(codex_exec_server::EnvironmentManager::without_environments()),
|
||||
home.path().to_path_buf(),
|
||||
),
|
||||
home.path().to_path_buf(),
|
||||
CodexAppsToolsCache::default(),
|
||||
codex_mcp::codex_apps_tools_cache_key(Some(&expected_auth)),
|
||||
/*prefix_mcp_tool_names*/ true,
|
||||
ElicitationCapability::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
ToolPluginProvenance::default(),
|
||||
Some(&expected_auth),
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
ElicitationRequestRouter::default(),
|
||||
)
|
||||
.await;
|
||||
// The model-provider test covers AuthManager reload behavior. Keep this
|
||||
// regression focused on core MCP wiring by updating the same shared
|
||||
// manager after the MCP client has been created.
|
||||
auth_manager
|
||||
.set_external_auth(Arc::new(StaticExternalAuth(
|
||||
CodexAuth::from_external_chatgpt_tokens(
|
||||
"header.e30.reloaded",
|
||||
"test-account",
|
||||
/*chatgpt_plan_type*/ None,
|
||||
)?,
|
||||
)))
|
||||
.await?;
|
||||
|
||||
// The manager and its static fallback were created before the auth update,
|
||||
// so this tool call only sees the new token if the Codex Apps provider
|
||||
// reads the shared AuthManager at request time.
|
||||
let tool_result = manager
|
||||
.call_tool(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"calendar_create_event",
|
||||
Some(json!({
|
||||
"title": "Lunch",
|
||||
"starts_at": "2026-06-18T12:00:00Z",
|
||||
})),
|
||||
/*meta*/ None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(tool_result.is_error, Some(false));
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("mock server should capture tool-call requests");
|
||||
let tool_call_request = requests
|
||||
.iter()
|
||||
.find(|request| {
|
||||
request.url.path() == "/api/codex/ps/mcp"
|
||||
&& serde_json::from_slice::<Value>(&request.body)
|
||||
.ok()
|
||||
.is_some_and(|body| {
|
||||
body.get("method").and_then(Value::as_str) == Some("tools/call")
|
||||
})
|
||||
})
|
||||
.expect("Codex Apps should receive a tool call");
|
||||
assert_eq!(
|
||||
tool_call_request
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("Bearer header.e30.reloaded")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -68,6 +68,7 @@ mod items;
|
||||
mod json_result;
|
||||
mod live_cli;
|
||||
mod mcp_auth_elicitation;
|
||||
mod mcp_auth_refresh;
|
||||
#[cfg(unix)]
|
||||
mod mcp_refresh_cleanup;
|
||||
mod mcp_tool_exposure;
|
||||
|
||||
@@ -121,6 +121,35 @@ impl AuthProvider for HeaderAuthProvider {
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthManagerAuthProvider {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
// Startup auth is only the account-scoped identity anchor. Request
|
||||
// headers always come from the current AuthManager snapshot below.
|
||||
expected_auth: CodexAuth,
|
||||
}
|
||||
|
||||
impl AuthProvider for AuthManagerAuthProvider {
|
||||
fn add_auth_headers(&self, headers: &mut HeaderMap) {
|
||||
let Some(auth) = self
|
||||
.auth_manager
|
||||
.auth_cached()
|
||||
.filter(CodexAuth::uses_codex_backend)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The caller's account-scoped state was built for the expected
|
||||
// identity. Follow token refreshes for that identity, but never cross
|
||||
// an account or workspace boundary without rebuilding that state.
|
||||
if auth.get_account_id() != self.expected_auth.get_account_id()
|
||||
|| auth.get_chatgpt_user_id() != self.expected_auth.get_chatgpt_user_id()
|
||||
|| auth.is_workspace_account() != self.expected_auth.is_workspace_account()
|
||||
{
|
||||
return;
|
||||
}
|
||||
auth_provider_from_auth(&auth).add_auth_headers(headers);
|
||||
}
|
||||
}
|
||||
|
||||
// Some providers are meant to send no auth headers. Examples include local OSS
|
||||
// providers and custom test providers with `requires_openai_auth = false`.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -268,6 +297,21 @@ pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds request-header auth that reads the current managed auth snapshot on
|
||||
/// every request while remaining scoped to the expected auth identity.
|
||||
///
|
||||
/// Callers with account-scoped state should pass the same snapshot that keyed
|
||||
/// that state so a later account switch cannot reuse it.
|
||||
pub fn auth_provider_from_auth_manager(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
expected_auth: &CodexAuth,
|
||||
) -> SharedAuthProvider {
|
||||
Arc::new(AuthManagerAuthProvider {
|
||||
auth_manager,
|
||||
expected_auth: expected_auth.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_agent_identity::generate_agent_key_material;
|
||||
@@ -275,9 +319,11 @@ mod tests {
|
||||
use codex_login::AuthKeyringBackendKind;
|
||||
use codex_login::auth::AgentIdentityAuthRecord;
|
||||
use codex_login::auth::BedrockApiKeyAuth;
|
||||
use codex_login::auth::login_with_chatgpt_auth_tokens;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use codex_model_provider_info::create_oss_provider_with_base_url;
|
||||
use codex_protocol::account::PlanType;
|
||||
use http::header::AUTHORIZATION;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
@@ -433,6 +479,64 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_manager_provider_follows_refreshes_but_not_account_switches() {
|
||||
let codex_home = test_codex_home();
|
||||
login_with_chatgpt_auth_tokens(
|
||||
&codex_home,
|
||||
"header.e30.first",
|
||||
"test-account",
|
||||
/*chatgpt_plan_type*/ None,
|
||||
)
|
||||
.expect("save initial auth");
|
||||
let auth_manager = Arc::new(
|
||||
AuthManager::new(
|
||||
codex_home.clone(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
AuthCredentialsStoreMode::Ephemeral,
|
||||
/*forced_chatgpt_workspace_id*/ None,
|
||||
/*chatgpt_base_url*/ None,
|
||||
AuthKeyringBackendKind::default(),
|
||||
/*auth_route_config*/ None,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
let expected_auth = auth_manager
|
||||
.auth_cached()
|
||||
.expect("initial auth should be cached");
|
||||
let provider = auth_provider_from_auth_manager(Arc::clone(&auth_manager), &expected_auth);
|
||||
|
||||
assert_eq!(
|
||||
provider.to_auth_headers().get(AUTHORIZATION),
|
||||
Some(&HeaderValue::from_static("Bearer header.e30.first"))
|
||||
);
|
||||
|
||||
login_with_chatgpt_auth_tokens(
|
||||
&codex_home,
|
||||
"header.e30.reloaded",
|
||||
"test-account",
|
||||
/*chatgpt_plan_type*/ None,
|
||||
)
|
||||
.expect("save reloaded auth");
|
||||
auth_manager.reload().await;
|
||||
|
||||
assert_eq!(
|
||||
provider.to_auth_headers().get(AUTHORIZATION),
|
||||
Some(&HeaderValue::from_static("Bearer header.e30.reloaded"))
|
||||
);
|
||||
|
||||
login_with_chatgpt_auth_tokens(
|
||||
&codex_home,
|
||||
"header.e30.other-account",
|
||||
"other-account",
|
||||
/*chatgpt_plan_type*/ None,
|
||||
)
|
||||
.expect("save switched-account auth");
|
||||
auth_manager.reload().await;
|
||||
|
||||
assert!(provider.to_auth_headers().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_party_run_scope_uses_agent_assertion_and_exposes_telemetry() {
|
||||
let auth = CodexAuth::AgentIdentity(
|
||||
|
||||
@@ -8,6 +8,7 @@ pub use auth::AgentIdentitySessionFallback;
|
||||
pub use auth::ProviderAuthScope;
|
||||
pub use auth::ResolvedProviderAuth;
|
||||
pub use auth::auth_provider_from_auth;
|
||||
pub use auth::auth_provider_from_auth_manager;
|
||||
pub use auth::unauthenticated_auth_provider;
|
||||
pub use bearer_auth_provider::BearerAuthProvider;
|
||||
pub use bearer_auth_provider::BearerAuthProvider as CoreAuthProvider;
|
||||
|
||||
Reference in New Issue
Block a user