mirror of
https://github.com/openai/codex.git
synced 2026-11-13 22:47:20 +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:
1 parent
20e5edfa74
commit
555aa79d5a
12 files changed
+355
-3
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user