mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Enable enterprise-managed authorization for MCP connections (#55334)
## What changed - Connect directly configured MCP servers using account-scoped enterprise grants stored in the keyring, with token exchange and authenticated HTTP requests. - Retain authorization for the thread across MCP refreshes and revoke it when the account owner changes, cloud configuration becomes invalid, or an enterprise login replaces the grant. - Negotiate the enterprise-managed authorization extension with the `2026-07-28` protocol, bypass shared tool catalog caching for enterprise servers, and report credential status with enterprise sign-in guidance. GitOrigin-RevId: c0f96d37194212758a211ae6a4107205df96df26
This commit is contained in:
@@ -240,11 +240,17 @@ impl EnterpriseLoginState {
|
||||
// Keep cancellation responsive during the async checks above.
|
||||
// Once admitted, retain this guard through the synchronous write.
|
||||
let active = lifecycle.active.lock().await;
|
||||
(active.as_ref().is_some_and(|attempt| {
|
||||
let current = active.as_ref().is_some_and(|attempt| {
|
||||
attempt.login_id == completion_id && !attempt.cancel.is_cancelled()
|
||||
})
|
||||
&& ema_auth_scope(lifecycle.auth_manager.auth_cached().as_ref()).as_ref() == Some(&scope))
|
||||
.then_some(active)
|
||||
&& ema_auth_scope(lifecycle.auth_manager.auth_cached().as_ref()).as_ref() == Some(&scope);
|
||||
if !current {
|
||||
return None;
|
||||
}
|
||||
// The credential lock is held: revoke cached bearers before replacing
|
||||
// the grant, and let new threads pin only the committed credential.
|
||||
lifecycle.config_manager.revoke_mcp_ema();
|
||||
Some(active)
|
||||
}).await?;
|
||||
// A successful commit is no longer cancelable. Clear it while
|
||||
// still holding the commit gate.
|
||||
|
||||
@@ -26,6 +26,7 @@ use startup::should_share_codex_apps_tools_cache;
|
||||
pub(crate) use tool_catalog::BindingCatalogRevision;
|
||||
pub use tool_catalog::tool_is_model_visible;
|
||||
|
||||
use codex_config::McpServerAuth;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
@@ -69,6 +70,8 @@ use codex_diagnostics::Gauge;
|
||||
use codex_diagnostics::GaugeGuard;
|
||||
use codex_otel::auth_storage::AuthStorageOriginator;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
use codex_protocol::mcp::ENTERPRISE_MANAGED_AUTHORIZATION_EXTENSION_ID;
|
||||
use codex_protocol::mcp::McpServerInfo;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -279,6 +282,7 @@ impl McpConnectionSet {
|
||||
/// Creates an MCP connection manager. Threadless callers can pass no `tx_event`; startup
|
||||
/// notifications are then skipped and interactive elicitations are declined.
|
||||
pub async fn new(
|
||||
ema_auth: Option<crate::ema_auth::EmaAuthContext>,
|
||||
previous: Option<&Self>,
|
||||
publication_gate: McpPublicationGate,
|
||||
input: McpRuntimeInput,
|
||||
@@ -408,6 +412,26 @@ impl McpConnectionSet {
|
||||
} else {
|
||||
default_protocol_mode
|
||||
};
|
||||
let uses_ema_auth =
|
||||
config.xaa_enabled && matches!(configured_config.auth, McpServerAuth::EmaAuth);
|
||||
let protocol_mode = if uses_ema_auth {
|
||||
crate::McpProtocolMode::V20260728
|
||||
} else {
|
||||
protocol_mode
|
||||
};
|
||||
let client_mcp_extensions = if uses_ema_auth {
|
||||
ClientMcpExtensions::new(
|
||||
client_mcp_extensions
|
||||
.iter()
|
||||
.map(|(id, settings)| (id.to_string(), settings.clone()))
|
||||
.chain(std::iter::once((
|
||||
ENTERPRISE_MANAGED_AUTHORIZATION_EXTENSION_ID.to_string(),
|
||||
serde_json::json!({}),
|
||||
))),
|
||||
)
|
||||
} else {
|
||||
client_mcp_extensions
|
||||
};
|
||||
let configured_tool_filter = ToolFilter::from_config(&configured_config);
|
||||
let startup_timeout = configured_config
|
||||
.startup_timeout_sec
|
||||
@@ -643,6 +667,7 @@ impl McpConnectionSet {
|
||||
let cancel_token = startup_cancellation_token.child_token();
|
||||
let mut tool_catalog_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME
|
||||
|| server.requires_read_only_mcp_tools()
|
||||
|| matches!(configured_config.auth, McpServerAuth::EmaAuth)
|
||||
{
|
||||
None
|
||||
} else if let Ok(environment) = resolved_environment.as_ref() {
|
||||
@@ -666,6 +691,7 @@ impl McpConnectionSet {
|
||||
}
|
||||
let has_runtime_auth = runtime_auth_provider.is_some();
|
||||
let async_managed_client = AsyncManagedClient::new(
|
||||
ema_auth.clone(),
|
||||
server_name.clone(),
|
||||
startup_submit_id.clone(),
|
||||
server,
|
||||
@@ -763,7 +789,11 @@ impl McpConnectionSet {
|
||||
}
|
||||
if let Some(tx_event) = tx_event.as_ref() {
|
||||
let auth_state = match &outcome {
|
||||
Err(error) if error.is_authentication_required() && !has_runtime_auth => {
|
||||
Err(error)
|
||||
if error.is_authentication_required()
|
||||
&& !has_runtime_auth
|
||||
&& !matches!(configured_config.auth, McpServerAuth::EmaAuth) =>
|
||||
{
|
||||
match &configured_config.transport {
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
|
||||
@@ -101,11 +101,14 @@ pub(super) fn mcp_init_error_display(
|
||||
"GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_key}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
)
|
||||
} else if error.is_authentication_required() {
|
||||
let recovery_hint = if config.is_some_and(|config| !config.is_local_environment()) {
|
||||
"Use your client's MCP OAuth sign-in flow.".to_string()
|
||||
} else {
|
||||
format!("Run `codex mcp login {server_name}`.")
|
||||
};
|
||||
let recovery_hint =
|
||||
if config.is_some_and(|config| matches!(config.auth, McpServerAuth::EmaAuth)) {
|
||||
"Sign in to your enterprise provider in Codex, then start a new thread.".to_string()
|
||||
} else if config.is_some_and(|config| !config.is_local_environment()) {
|
||||
"Use your client's MCP OAuth sign-in flow.".to_string()
|
||||
} else {
|
||||
format!("Run `codex mcp login {server_name}`.")
|
||||
};
|
||||
let auth_status = match reason {
|
||||
Some(McpStartupFailureReason::ReauthenticationRequired) => {
|
||||
"requires OAuth reauthentication"
|
||||
|
||||
@@ -2059,6 +2059,7 @@ async fn read_only_apps_discovery_never_uses_a_shared_writable_catalog() -> anyh
|
||||
let cancellation = CancellationToken::new();
|
||||
cancellation.cancel();
|
||||
let manager = McpConnectionSet::new(
|
||||
/*ema_auth*/ None,
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
@@ -2132,6 +2133,7 @@ async fn hosted_apps_protocol_mode_is_independent_of_generic_mode() -> anyhow::R
|
||||
let startup_cancellation_token = CancellationToken::new();
|
||||
startup_cancellation_token.cancel();
|
||||
let manager = McpConnectionSet::new(
|
||||
/*ema_auth*/ None,
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
@@ -2238,6 +2240,7 @@ async fn codex_apps_extension_does_not_share_host_owned_tools_cache() -> anyhow:
|
||||
let startup_cancellation_token = CancellationToken::new();
|
||||
startup_cancellation_token.cancel();
|
||||
let manager = McpConnectionSet::new(
|
||||
/*ema_auth*/ None,
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
@@ -4807,6 +4810,7 @@ async fn executor_owned_chatgpt_mcp_accepts_only_safe_explicit_authorization() -
|
||||
);
|
||||
|
||||
let manager = McpConnectionSet::new(
|
||||
/*ema_auth*/ None,
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
@@ -4925,6 +4929,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
|
||||
|
||||
let cancel_token = CancellationToken::new();
|
||||
let manager = McpConnectionSet::new(
|
||||
/*ema_auth*/ None,
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
@@ -5373,6 +5378,7 @@ async fn reconcile_reusable_server_with_mcp_config(
|
||||
) -> McpConnectionSet {
|
||||
let (tx_event, _rx_event) = async_channel::unbounded();
|
||||
McpConnectionSet::new(
|
||||
/*ema_auth*/ None,
|
||||
Some(previous),
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
@@ -6329,6 +6335,7 @@ async fn reconciliation_replaces_connection_when_protocol_mode_changes() {
|
||||
mcp_config.protocol_mode = codex_rmcp_client::McpProtocolMode::V20260728;
|
||||
|
||||
let reconciled = McpConnectionSet::new(
|
||||
/*ema_auth*/ None,
|
||||
Some(&previous),
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
@@ -6387,6 +6394,7 @@ async fn reconciliation_reuses_legacy_stdio_server_when_modern_protocol_is_enabl
|
||||
mcp_config.protocol_mode = codex_rmcp_client::McpProtocolMode::V20260728;
|
||||
|
||||
let reconciled = McpConnectionSet::new(
|
||||
/*ema_auth*/ None,
|
||||
Some(&previous),
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
|
||||
@@ -1,9 +1,345 @@
|
||||
//! Derives the enterprise credential owner from the active Codex account.
|
||||
//! Dedicated enterprise grants and one thread's EMA authorization lifetime.
|
||||
//! Account credentials identify the owner; only the separate keyring grant is exchanged.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use codex_config::CloudConfigBundleBindingStatus;
|
||||
use codex_config::McpEmaAuthScope;
|
||||
use codex_config::McpEmaRegistration;
|
||||
use codex_config::McpServerIdpOAuthConfig;
|
||||
use codex_config::types::AuthKeyringBackendKind;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_exec_server::ExecServerError;
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_exec_server::HttpRequestParams;
|
||||
use codex_exec_server::HttpRequestResponse;
|
||||
use codex_exec_server::HttpResponseBodyStream;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_rmcp_client::EmaAccessToken;
|
||||
use codex_rmcp_client::EmaAuthFailure;
|
||||
use codex_rmcp_client::EmaAuthTokenExchangeRequest;
|
||||
use codex_rmcp_client::EmaAuthenticatedHttpClient;
|
||||
use codex_rmcp_client::EmaCredentialSnapshot;
|
||||
use codex_rmcp_client::EmaIdpIdentityRequest;
|
||||
use codex_rmcp_client::EmaInvalidGrantSource;
|
||||
use codex_rmcp_client::EmaRequestAuthority;
|
||||
use codex_rmcp_client::EmaTokenSource;
|
||||
use codex_rmcp_client::McpOAuthRefreshMode;
|
||||
use codex_rmcp_client::McpProtocolMode;
|
||||
use codex_rmcp_client::RmcpClient;
|
||||
use codex_rmcp_client::StoredOAuthCredentialSnapshot;
|
||||
use codex_rmcp_client::StreamableHttpBearerToken;
|
||||
use codex_rmcp_client::StreamableHttpRedirectMode;
|
||||
use codex_rmcp_client::exchange_ema_auth_token;
|
||||
use codex_rmcp_client::resolve_ema_idp_identity;
|
||||
use codex_rmcp_client::stored_ema_identity_is_usable;
|
||||
use codex_rmcp_client::stored_oauth_credential_snapshot;
|
||||
use futures::future::BoxFuture;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
use url::Url;
|
||||
|
||||
use crate::EffectiveMcpServer;
|
||||
use crate::McpConfig;
|
||||
use crate::McpServerSource;
|
||||
use crate::rmcp_client::StartupOutcomeError;
|
||||
|
||||
pub(crate) type EmaAuthContext = Result<Arc<EmaAuth>, StartupOutcomeError>;
|
||||
|
||||
pub fn ema_auth_scope(auth: Option<&CodexAuth>) -> Option<McpEmaAuthScope> {
|
||||
let auth = auth?;
|
||||
McpEmaAuthScope::new(auth.get_chatgpt_user_id()?, auth.get_account_id()?)
|
||||
}
|
||||
|
||||
/// Retained by the thread across MCP refreshes; a revoked owner cannot be replaced.
|
||||
pub(crate) struct EmaAuth {
|
||||
scope: McpEmaAuthScope,
|
||||
config: Arc<McpConfig>,
|
||||
authority: EmaRequestAuthority,
|
||||
_revocation: AbortOnDropHandle<()>,
|
||||
}
|
||||
|
||||
impl EmaAuth {
|
||||
pub(crate) fn new(
|
||||
config: Arc<McpConfig>,
|
||||
auth: Option<&CodexAuth>,
|
||||
auth_manager: Option<&Arc<AuthManager>>,
|
||||
) -> Result<Arc<Self>> {
|
||||
let scope = ema_auth_scope(auth).ok_or(EmaAuthFailure::ReauthenticationRequired)?;
|
||||
let manager = auth_manager.context("enterprise MCP requires an account owner")?;
|
||||
let mut changes = manager.auth_change_state_receiver();
|
||||
let owner_generation = changes.borrow().owner_generation;
|
||||
// Never hold the watch guard while reading cached auth: writers take the auth lock first.
|
||||
if ema_auth_scope(manager.auth_cached().as_ref()).as_ref() != Some(&scope)
|
||||
|| changes.borrow().owner_generation != owner_generation
|
||||
{
|
||||
bail!(EmaAuthFailure::ReauthenticationRequired);
|
||||
}
|
||||
let cancellation = config.mcp_ema_cancellation.clone();
|
||||
let policy = config.config_layer_stack.cloud_config_binding().cloned();
|
||||
let authority = EmaRequestAuthority::new(cancellation.clone(), {
|
||||
let changes = changes.clone();
|
||||
let policy = policy.clone();
|
||||
move || {
|
||||
changes.borrow().owner_generation == owner_generation
|
||||
&& policy.as_ref().is_none_or(|binding| {
|
||||
binding.read().status == CloudConfigBundleBindingStatus::Current
|
||||
})
|
||||
}
|
||||
});
|
||||
authority.check()?;
|
||||
let revocation = tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancellation.cancelled() => {}
|
||||
() = async {
|
||||
match policy {
|
||||
Some(binding) => binding.invalidated().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
} => {}
|
||||
() = async {
|
||||
while changes.changed().await.is_ok() {
|
||||
if changes.borrow().owner_generation != owner_generation {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} => {}
|
||||
}
|
||||
cancellation.cancel();
|
||||
});
|
||||
Ok(Arc::new(Self {
|
||||
scope,
|
||||
config,
|
||||
authority,
|
||||
_revocation: AbortOnDropHandle::new(revocation),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn connect(
|
||||
self: &Arc<Self>,
|
||||
server_name: &str,
|
||||
server: &EffectiveMcpServer,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
protocol_mode: McpProtocolMode,
|
||||
) -> Result<RmcpClient> {
|
||||
self.authority.check()?;
|
||||
let config = server.config();
|
||||
config
|
||||
.validate_ema_auth_transport()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if !self.config.xaa_enabled
|
||||
|| !self
|
||||
.config
|
||||
.mcp_server_catalog
|
||||
.server(server_name)
|
||||
.is_some_and(|server| matches!(server.source(), McpServerSource::Config))
|
||||
{
|
||||
bail!("enterprise MCP requires an enabled, directly configured server");
|
||||
}
|
||||
let registration = config
|
||||
.ema_registration()
|
||||
.context("enterprise MCP registration is missing")?
|
||||
.clone();
|
||||
let credentials = stored_ema_credentials(
|
||||
registration.idp(),
|
||||
&self.scope,
|
||||
self.config.auth_keyring_backend_kind,
|
||||
)
|
||||
.await?
|
||||
.ok_or(EmaAuthFailure::ReauthenticationRequired)?
|
||||
.pin_ema_credentials()
|
||||
.await?;
|
||||
let exchange_client: Arc<dyn HttpClient> = Arc::new(EnterpriseExchangeHttpClient {
|
||||
inner: Arc::clone(&http_client),
|
||||
authority: self.authority.clone(),
|
||||
cancellation: self.config.mcp_ema_cancellation.clone(),
|
||||
});
|
||||
let url = registration.server_url().to_owned();
|
||||
let source = EnterpriseTokenSource {
|
||||
endpoint: Url::parse(registration.server_url())?,
|
||||
registration,
|
||||
credentials,
|
||||
auth: Arc::clone(self),
|
||||
http_client: exchange_client,
|
||||
};
|
||||
let token = source.access_token().await?;
|
||||
let http_client = Arc::new(EmaAuthenticatedHttpClient::new(
|
||||
http_client,
|
||||
source,
|
||||
token,
|
||||
self.authority.clone(),
|
||||
));
|
||||
RmcpClient::new_streamable_http_client_with_protocol_mode_and_redirect_mode(
|
||||
server_name,
|
||||
&url,
|
||||
Some(StreamableHttpBearerToken::ProvidedByHttpClient),
|
||||
/*http_headers*/ None,
|
||||
/*env_http_headers*/ None,
|
||||
/*oauth_config*/ None,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
self.config.auth_keyring_backend_kind,
|
||||
http_client,
|
||||
/*auth_provider*/ None,
|
||||
protocol_mode,
|
||||
StreamableHttpRedirectMode::Legacy,
|
||||
McpOAuthRefreshMode::Legacy,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn stored_ema_credentials(
|
||||
idp: &McpServerIdpOAuthConfig,
|
||||
scope: &McpEmaAuthScope,
|
||||
keyring_backend: AuthKeyringBackendKind,
|
||||
) -> Result<Option<StoredOAuthCredentialSnapshot>> {
|
||||
let name = idp.credential_name(scope);
|
||||
let idp = idp.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tracing::subscriber::with_default(tracing::subscriber::NoSubscriber::default(), || {
|
||||
stored_oauth_credential_snapshot(
|
||||
&name,
|
||||
&idp.issuer,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
keyring_backend,
|
||||
)
|
||||
.map(|snapshot| {
|
||||
snapshot.filter(|snapshot| {
|
||||
stored_ema_identity_is_usable(
|
||||
snapshot.credentials(),
|
||||
&idp.issuer,
|
||||
&idp.client_id,
|
||||
)
|
||||
})
|
||||
})
|
||||
.map_err(|_| anyhow!("failed to read enterprise credentials"))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow!("enterprise credential read task failed"))?
|
||||
}
|
||||
|
||||
// Discovery and both credential exchanges must check the owner at each dispatch,
|
||||
// including when the revocation watcher has not yet been scheduled.
|
||||
struct EnterpriseExchangeHttpClient {
|
||||
inner: Arc<dyn HttpClient>,
|
||||
authority: EmaRequestAuthority,
|
||||
cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
impl HttpClient for EnterpriseExchangeHttpClient {
|
||||
fn http_request(
|
||||
&self,
|
||||
params: HttpRequestParams,
|
||||
) -> BoxFuture<'_, Result<HttpRequestResponse, ExecServerError>> {
|
||||
Box::pin(async move {
|
||||
self.authority.check()?;
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = self.cancellation.cancelled() => Err(ExecServerError::HttpRequest("enterprise MCP authorization was revoked".into())),
|
||||
response = self.inner.http_request(params) => {
|
||||
self.authority.check()?;
|
||||
response
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn http_request_stream(
|
||||
&self,
|
||||
params: HttpRequestParams,
|
||||
) -> BoxFuture<'_, Result<(HttpRequestResponse, HttpResponseBodyStream), ExecServerError>> {
|
||||
Box::pin(async move {
|
||||
self.authority.check()?;
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = self.cancellation.cancelled() => Err(ExecServerError::HttpRequest("enterprise MCP authorization was revoked".into())),
|
||||
response = self.inner.http_request_stream(params) => {
|
||||
self.authority.check()?;
|
||||
response.map(|(response, body)| (response, body.cancel_on(self.cancellation.clone())))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct EnterpriseTokenSource {
|
||||
endpoint: Url,
|
||||
registration: McpEmaRegistration,
|
||||
credentials: EmaCredentialSnapshot,
|
||||
auth: Arc<EmaAuth>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
}
|
||||
|
||||
impl EmaTokenSource for EnterpriseTokenSource {
|
||||
fn endpoint(&self) -> &Url {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
async fn access_token(&self) -> Result<EmaAccessToken> {
|
||||
self.auth.authority.check()?;
|
||||
let idp = self.registration.idp();
|
||||
let exchange = exchange_ema_auth_token(EmaAuthTokenExchangeRequest {
|
||||
server_url: self.registration.server_url(),
|
||||
resource: self.registration.resource(),
|
||||
scopes: self.registration.scopes(),
|
||||
mcp_client_id: self.registration.client_id(),
|
||||
expected_authorization_server_issuer: self.registration.authorization_server_issuer(),
|
||||
idp_issuer: &idp.issuer,
|
||||
idp_client_id: &idp.client_id,
|
||||
idp_identity: Box::pin(async {
|
||||
self.auth.authority.check()?;
|
||||
resolve_ema_idp_identity(EmaIdpIdentityRequest {
|
||||
issuer: &idp.issuer,
|
||||
client_id: &idp.client_id,
|
||||
credentials: self.credentials.acquire_current_ema_credentials().await?,
|
||||
http_client: Arc::clone(&self.http_client),
|
||||
redirect_mode: StreamableHttpRedirectMode::Legacy,
|
||||
})
|
||||
.await
|
||||
}),
|
||||
idp_http_client: Arc::clone(&self.http_client),
|
||||
resource_http_client: Arc::clone(&self.http_client),
|
||||
});
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = self.auth.config.mcp_ema_cancellation.cancelled() => {
|
||||
bail!(EmaAuthFailure::ReauthenticationRequired);
|
||||
}
|
||||
result = exchange => result,
|
||||
};
|
||||
self.auth.authority.check()?;
|
||||
match result {
|
||||
Ok((token, _credentials)) => {
|
||||
self.credentials.acquire_current_ema_credentials().await?;
|
||||
self.auth.authority.check()?;
|
||||
Ok(token)
|
||||
}
|
||||
Err(error) => {
|
||||
if matches!(
|
||||
error.downcast_ref::<EmaAuthFailure>(),
|
||||
Some(EmaAuthFailure::InvalidGrant {
|
||||
grant_source: EmaInvalidGrantSource::EnterpriseIdentity,
|
||||
})
|
||||
) && self
|
||||
.credentials
|
||||
.invalidate_ema_credentials_if_current()
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("failed to clear rejected enterprise credentials");
|
||||
}
|
||||
Err(match error.downcast::<EmaAuthFailure>() {
|
||||
Ok(failure) => failure.into(),
|
||||
Err(_) => anyhow!("enterprise MCP token exchange failed"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ impl McpEventStreamOpener {
|
||||
let startup_timeout = connection.server.config().startup_timeout_sec
|
||||
.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
|
||||
let client = Arc::new(tokio::time::timeout(startup_timeout, make_rmcp_client(
|
||||
/*ema_auth*/ None,
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
connection.server.clone(),
|
||||
connection.store_mode,
|
||||
|
||||
@@ -10,6 +10,7 @@ use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_rmcp_client::McpAuthState;
|
||||
use codex_rmcp_client::McpLoginRequirement;
|
||||
use codex_rmcp_client::McpOAuthCallbackMode;
|
||||
use codex_rmcp_client::OAuthDiscoveryTimeout;
|
||||
use codex_rmcp_client::OAuthProviderError;
|
||||
@@ -255,8 +256,29 @@ async fn compute_auth_status(
|
||||
return Ok(McpAuthState::Unsupported);
|
||||
}
|
||||
if matches!(config.auth, McpServerAuth::EmaAuth) {
|
||||
// EMA connections are not enabled until the runtime stage of the stack.
|
||||
return Ok(McpAuthState::Unsupported);
|
||||
let Some(registration) = config.ema_registration() else {
|
||||
return Ok(McpAuthState::Unsupported);
|
||||
};
|
||||
config
|
||||
.validate_ema_auth_transport()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let Some(scope) = crate::ema_auth_scope(auth) else {
|
||||
return Ok(McpAuthState::LoggedOut(McpLoginRequirement::Login));
|
||||
};
|
||||
return Ok(
|
||||
if crate::ema_auth::stored_ema_credentials(
|
||||
registration.idp(),
|
||||
&scope,
|
||||
keyring_backend_kind,
|
||||
)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
McpAuthState::OAuth
|
||||
} else {
|
||||
McpAuthState::LoggedOut(McpLoginRequirement::Login)
|
||||
},
|
||||
);
|
||||
}
|
||||
let has_runtime_auth = matches!(config.auth, McpServerAuth::ChatGpt)
|
||||
&& auth.is_some_and(CodexAuth::uses_codex_backend)
|
||||
|
||||
@@ -463,6 +463,7 @@ pub async fn read_mcp_resource(
|
||||
let cancel_token = CancellationToken::new();
|
||||
let runtime_config = config.for_threadless_operations(&mcp_servers);
|
||||
let manager = McpConnectionSet::new(
|
||||
/*ema_auth*/ None,
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
@@ -545,6 +546,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
let cancel_token = CancellationToken::new();
|
||||
let runtime_config = config.for_threadless_operations(&mcp_servers);
|
||||
let mcp_connection_manager = McpConnectionSet::new(
|
||||
/*ema_auth*/ None,
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
|
||||
@@ -297,6 +297,7 @@ fn codex_apps_reconnect_backoff(consecutive_failures: u32) -> Duration {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ManagedClientStartup {
|
||||
ema_auth: Option<crate::ema_auth::EmaAuthContext>,
|
||||
originator: AuthStorageOriginator,
|
||||
server_name: String,
|
||||
server: EffectiveMcpServer,
|
||||
@@ -328,6 +329,7 @@ impl ManagedClientStartup {
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
let Self {
|
||||
ema_auth,
|
||||
originator,
|
||||
server_name,
|
||||
server,
|
||||
@@ -369,6 +371,7 @@ impl ManagedClientStartup {
|
||||
let client = match tokio::time::timeout(
|
||||
startup_timeout,
|
||||
make_rmcp_client(
|
||||
ema_auth,
|
||||
&server_name,
|
||||
server.clone(),
|
||||
store_mode,
|
||||
@@ -462,6 +465,7 @@ impl AsyncManagedClient {
|
||||
#[instrument(level = "trace", skip_all, fields(server_name = %server_name))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
ema_auth: Option<crate::ema_auth::EmaAuthContext>,
|
||||
server_name: String,
|
||||
startup_submit_id: String,
|
||||
server: EffectiveMcpServer,
|
||||
@@ -495,6 +499,7 @@ impl AsyncManagedClient {
|
||||
let startup_complete = Arc::new(AtomicBool::new(false));
|
||||
let server_capabilities = Arc::new(StdMutex::new(None));
|
||||
let startup = Arc::new(ManagedClientStartup {
|
||||
ema_auth,
|
||||
originator: AuthStorageOriginator::current(),
|
||||
server_name,
|
||||
server,
|
||||
@@ -1236,6 +1241,7 @@ struct StartServerTaskParams {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[instrument(level = "trace", skip_all, fields(server_name = %server_name))]
|
||||
pub(crate) async fn make_rmcp_client(
|
||||
ema_auth: Option<crate::ema_auth::EmaAuthContext>,
|
||||
server_name: &str,
|
||||
server: EffectiveMcpServer,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
@@ -1265,9 +1271,20 @@ pub(crate) async fn make_rmcp_client(
|
||||
}
|
||||
}
|
||||
if matches!(config.auth, McpServerAuth::EmaAuth) {
|
||||
return Err(StartupOutcomeError::from(anyhow!(
|
||||
"EMA MCP connections are not enabled in this version"
|
||||
)));
|
||||
let auth = ema_auth.ok_or_else(|| {
|
||||
StartupOutcomeError::from(anyhow!(
|
||||
"enterprise MCP requires a thread with an account owner"
|
||||
))
|
||||
})??;
|
||||
return auth
|
||||
.connect(
|
||||
server_name,
|
||||
&server,
|
||||
runtime_context.local_http_client(),
|
||||
protocol_mode,
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into);
|
||||
}
|
||||
if matches!(config.auth, McpServerAuth::ChatGpt)
|
||||
&& !config.is_local_environment()
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::Weak;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
@@ -112,6 +113,7 @@ pub struct McpRuntime {
|
||||
resource_cache_generation: AtomicU64,
|
||||
elicitation_router: ElicitationRequestRouter,
|
||||
resource_origins: Mutex<ResourceOrigins>,
|
||||
ema_auth: OnceLock<crate::ema_auth::EmaAuthContext>,
|
||||
}
|
||||
|
||||
struct EventStreamCancellation {
|
||||
@@ -251,6 +253,7 @@ impl McpRuntime {
|
||||
resource_cache_generation: AtomicU64::new(0),
|
||||
elicitation_router: ElicitationRequestRouter::default(),
|
||||
resource_origins: Mutex::default(),
|
||||
ema_auth: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +338,23 @@ impl McpRuntime {
|
||||
}
|
||||
|
||||
async fn publish(&self, input: McpRuntimeInput, previous: Option<&McpConnectionSet>) {
|
||||
let ema_auth = (input.config.xaa_enabled
|
||||
&& input.mcp_servers.values().any(|server| {
|
||||
server.config().enabled
|
||||
&& matches!(server.config().auth, codex_config::McpServerAuth::EmaAuth)
|
||||
}))
|
||||
.then(|| {
|
||||
self.ema_auth
|
||||
.get_or_init(|| {
|
||||
crate::ema_auth::EmaAuth::new(
|
||||
Arc::clone(&input.config),
|
||||
input.auth.as_ref(),
|
||||
input.auth_manager.as_ref(),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
})
|
||||
.clone()
|
||||
});
|
||||
let (publish, publication_gate) = McpPublicationGate::pending();
|
||||
let config = Arc::clone(&input.config);
|
||||
let auth = input.auth.clone();
|
||||
@@ -351,6 +371,7 @@ impl McpRuntime {
|
||||
let ready_environments = input.runtime_context.ready_environments.clone();
|
||||
let connections = Arc::new(
|
||||
McpConnectionSet::new(
|
||||
ema_auth,
|
||||
previous,
|
||||
publication_gate,
|
||||
input,
|
||||
|
||||
Reference in New Issue
Block a user