Add trusted enterprise MCP auth configuration (#44832)

## Why

Enterprise MCP authorization must remain controlled by host, user, or managed configuration. Project settings and plugin declarations must not redirect enterprise credentials or downgrade the selected authentication mode.

## What changed

- Add `auth = "ema_auth"`, shared `mcp_enterprise_managed_auth` IdP configuration, and per-plugin enterprise registration settings.
- Require enterprise registrations to come from one non-project configuration layer, preserve managed IdP precedence, and reject project overrides that change authorization or re-enable disabled enterprise servers.
- Add the disabled-by-default `use_xaa` feature flag and require non-project opt-in or a managed requirement.
- Reject plugin declarations that select `ema_auth`. Keep EMA connections unsupported and block ordinary MCP OAuth login and credential fallback for this mode.
- Extend the configuration schema and serialization for the new settings.

## Testing

Add coverage for IdP precedence, incomplete registrations, alternate credential validation, project-only opt-in, plugin self-declaration rejection, configuration round-tripping, and trusted-project attempts to downgrade enterprise auth to OAuth or ChatGPT auth.

GitOrigin-RevId: f193bdfda74208c98e496a23bf2916955b131a1c
This commit is contained in:
Nick Steele
2026-09-11 14:25:56 +00:00
committed by copyberry
parent 7a6f469dcf
commit 654b0a77d0
28 changed files with 872 additions and 31 deletions
@@ -170,6 +170,11 @@ impl McpRequestProcessor {
StreamableHttpRedirectMode::Legacy
};
let server = server.config();
if matches!(server.auth, codex_config::McpServerAuth::EmaAuth) {
return Err(invalid_request(
"EMA MCP connections are not enabled in this version",
));
}
let (url, http_headers, env_http_headers) = match &server.transport {
McpServerTransportConfig::StreamableHttp {
@@ -1806,7 +1806,9 @@ impl PluginRequestProcessor {
config.cwd.to_path_buf(),
);
for (name, server) in plugin_mcp_servers {
if !server.enabled {
// EMA uses the account's enterprise grant, never per-plugin OAuth fallback.
if !server.enabled || matches!(server.auth, codex_config::types::McpServerAuth::EmaAuth)
{
continue;
}
if !server.is_local_environment() {
+7
View File
@@ -406,6 +406,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
client_id: Some(client_id),
callback_url: callback_url.clone(),
callback_port: None,
..Default::default()
}),
oauth_resource: oauth_resource.clone(),
tools: HashMap::new(),
@@ -525,6 +526,12 @@ async fn run_login(config: &Config, login_args: LoginArgs) -> Result<()> {
bail!("No MCP server named '{name}' found.");
};
if matches!(server.auth, codex_config::types::McpServerAuth::EmaAuth) {
bail!(
"Enterprise MCP authorization is managed by Codex account sign-in. Open Codex to sign in."
);
}
let (url, http_headers, env_http_headers) = match &server.transport {
McpServerTransportConfig::StreamableHttp {
url,
+15 -11
View File
@@ -213,22 +213,13 @@ where
};
let config = server.config().clone();
let runtime_context = runtime_context.clone();
let has_runtime_auth = matches!(&config.auth, McpServerAuth::ChatGpt)
&& auth.is_some_and(CodexAuth::uses_codex_backend)
&& matches!(
&config.transport,
McpServerTransportConfig::StreamableHttp {
bearer_token_env_var: None,
..
}
);
async move {
let auth_state = match compute_auth_status(
&name,
&config,
store_mode,
keyring_backend_kind,
has_runtime_auth,
auth,
&runtime_context,
redirect_mode,
)
@@ -256,13 +247,26 @@ async fn compute_auth_status(
config: &McpServerConfig,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
has_runtime_auth: bool,
auth: Option<&CodexAuth>,
runtime_context: &McpRuntimeContext,
redirect_mode: StreamableHttpRedirectMode,
) -> Result<McpAuthState> {
if !config.enabled {
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 has_runtime_auth = matches!(config.auth, McpServerAuth::ChatGpt)
&& auth.is_some_and(CodexAuth::uses_codex_backend)
&& matches!(
&config.transport,
McpServerTransportConfig::StreamableHttp {
bearer_token_env_var: None,
..
}
);
if matches!(config.auth, McpServerAuth::ChatGpt) && !config.is_local_environment() {
return Ok(if has_explicit_http_authorization(config) {
+1 -1
View File
@@ -398,7 +398,7 @@ pub fn effective_mcp_servers_from_configured(
server.auth = McpServerAuth::OAuth;
}
}
McpServerAuth::OAuth => {}
McpServerAuth::OAuth | McpServerAuth::EmaAuth => {}
}
let agent_plugin = config
.mcp_server_catalog
+7
View File
@@ -1,3 +1,4 @@
use codex_config::McpServerAuth;
use codex_config::McpServerConfig;
use codex_config::McpServerEnvVar;
use codex_config::McpServerTransportConfig;
@@ -155,6 +156,12 @@ fn normalize_plugin_mcp_server(
let mut config = serde_json::from_value::<McpServerConfig>(JsonValue::Object(object))
.map_err(|err| err.to_string())?;
if matches!(config.auth, McpServerAuth::EmaAuth) {
return Err(
"plugin MCP declarations cannot select ema_auth; configure enterprise authentication in host policy"
.to_string(),
);
}
if matches!(source, PluginMcpSource::Environment { .. }) {
bind_environment_env_vars(&mut config)?;
}
@@ -584,6 +584,7 @@ fn declared_placement_preserves_local_plugin_normalization() {
client_id: Some("client-id".to_string()),
callback_url: Some("http://127.0.0.1/callback/registered".to_string()),
callback_port: Some(9876),
..Default::default()
}),
oauth_resource: None,
tools: HashMap::new(),
@@ -632,6 +633,33 @@ fn declared_placement_preserves_local_plugin_normalization() {
);
}
#[test]
fn native_plugin_mcp_cannot_self_declare_ema_auth() {
let server = serde_json::json!({
"type": "http",
"url": "https://resource.example/mcp",
"auth": "ema_auth"
});
for contents in [
serde_json::json!({"enterprise": server}),
serde_json::json!({"mcpServers": {"enterprise": server}}),
] {
let outcome = parse_plugin_mcp_config(&plugin_root(), &contents.to_string())
.expect("parse plugin MCP config");
assert_eq!(
outcome,
PluginMcpConfigParseOutcome {
servers: BTreeMap::new(),
errors: vec![PluginMcpServerParseError {
name: "enterprise".to_string(),
message: "plugin MCP declarations cannot select ema_auth; configure enterprise authentication in host policy".to_string(),
}],
}
);
}
}
#[test]
fn environment_placement_forces_authority_and_defaults_null_cwd() {
let plugin_root = plugin_root();
+5
View File
@@ -1145,6 +1145,11 @@ pub(crate) async fn make_rmcp_client(
protocol_mode: McpProtocolMode,
) -> Result<RmcpClient, StartupOutcomeError> {
let config = server.config().clone();
if matches!(config.auth, McpServerAuth::EmaAuth) {
return Err(StartupOutcomeError::from(anyhow!(
"EMA MCP connections are not enabled in this version"
)));
}
if matches!(config.auth, McpServerAuth::ChatGpt)
&& !config.is_local_environment()
&& !has_explicit_http_authorization(&config)
+1
View File
@@ -139,6 +139,7 @@ impl McpServerConnectionIdentity {
.all(|byte| byte == b'\t' || (byte >= b' ' && byte != 0x7f))
};
let stored_oauth_url = if runtime_auth_provider.is_none()
&& !matches!(config.auth, McpServerAuth::EmaAuth)
&& (!matches!(config.auth, McpServerAuth::ChatGpt) || config.is_local_environment())
{
match &config.transport {
+5
View File
@@ -17,6 +17,7 @@ use crate::types::AuthCredentialsStoreMode;
use crate::types::FeedbackConfigToml;
use crate::types::History;
use crate::types::MarketplaceConfig;
use crate::types::McpEnterpriseManagedAuthConfig;
use crate::types::McpServerConfig;
use crate::types::MemoriesToml;
use crate::types::Notice;
@@ -276,6 +277,10 @@ pub struct ConfigToml {
#[schemars(schema_with = "crate::schema::mcp_servers_schema")]
pub mcp_servers: HashMap<String, McpServerConfig>,
/// Trusted enterprise IdP shared by EMA-enabled MCP servers and plugins.
#[serde(default)]
pub mcp_enterprise_managed_auth: Option<McpEnterpriseManagedAuthConfig>,
/// Preferred backend for storing MCP OAuth credentials.
/// keyring: Use an OS-specific keyring service.
/// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2
+4
View File
@@ -21,6 +21,7 @@ mod key_aliases;
pub mod loader;
mod marketplace_edit;
mod mcp_edit;
mod mcp_ema;
mod mcp_requirements;
mod mcp_types;
mod merge;
@@ -156,6 +157,9 @@ pub use marketplace_edit::record_user_marketplace;
pub use marketplace_edit::remove_user_marketplace;
pub use marketplace_edit::remove_user_marketplace_config;
pub use mcp_edit::load_global_mcp_servers;
pub use mcp_ema::McpEmaRegistration;
pub use mcp_ema::McpEnterpriseManagedAuthConfig;
pub use mcp_ema::McpServerIdpOAuthConfig;
pub use mcp_types::AppToolApproval;
pub use mcp_types::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
pub use mcp_types::McpServerAuth;
+351
View File
@@ -0,0 +1,351 @@
//! Enterprise MCP configuration and registration provenance.
//!
//! Only host, user, or managed configuration selects the IdP. Plugin declarations
//! and project settings may not redirect the enterprise credential source.
use std::io;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use codex_features::Feature;
use crate::ConfigLayerSource;
use crate::ConfigLayerStack;
use crate::McpServerConfig;
use crate::McpServerTransportConfig;
use crate::types::PluginMcpServerEmaAuthConfig;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct McpServerIdpOAuthConfig {
/// Issuer used for enterprise OAuth discovery and identity validation.
pub issuer: String,
/// Public OAuth client registered with that enterprise IdP.
pub client_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct McpEnterpriseManagedAuthConfig {
/// Shared enterprise authorization, independent of Codex account credentials.
pub idp: McpServerIdpOAuthConfig,
}
/// Immutable authorization selected after trusted policy and catalog resolution.
/// This cannot be deserialized from a server or plugin declaration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpEmaRegistration {
idp: McpServerIdpOAuthConfig,
server_url: String,
resource: Option<String>,
client_id: Option<String>,
authorization_server_issuer: Option<String>,
scopes: Vec<String>,
}
impl McpEmaRegistration {
pub fn idp(&self) -> &McpServerIdpOAuthConfig {
&self.idp
}
pub fn server_url(&self) -> &str {
&self.server_url
}
pub fn resource(&self) -> Option<&str> {
self.resource.as_deref()
}
pub fn client_id(&self) -> Option<&str> {
self.client_id.as_deref()
}
pub fn authorization_server_issuer(&self) -> Option<&str> {
self.authorization_server_issuer.as_deref()
}
pub fn scopes(&self) -> &[String] {
&self.scopes
}
}
impl McpEnterpriseManagedAuthConfig {
/// Resolves the trusted profile and checks provenance at a config load boundary.
/// Plugin endpoints are checked later, when their declarations are materialized.
pub fn resolve(
stack: &ConfigLayerStack,
fallback: Option<&Self>,
servers: &std::collections::HashMap<String, McpServerConfig>,
xaa_enabled: bool,
) -> io::Result<Option<Self>> {
validate_ema_auth_sources(stack, servers)?;
validate_xaa_opt_in_source(stack, xaa_enabled)?;
Self::from_config_layers(stack, fallback)
}
/// Selects one complete trusted registration rather than merging issuer/client pairs.
fn from_config_layers(
stack: &ConfigLayerStack,
fallback: Option<&Self>,
) -> io::Result<Option<Self>> {
if stack.layers_high_to_low().next().is_none() {
return Ok(fallback.cloned());
}
let sections = || {
stack.layers_high_to_low().filter_map(|layer| {
layer
.config
.get("mcp_enterprise_managed_auth")
.map(|section| (&layer.name, section))
})
};
let selected = sections()
.find(|(source, _)| {
matches!(
source,
ConfigLayerSource::Mdm { .. }
| ConfigLayerSource::System { .. }
| ConfigLayerSource::EnterpriseManaged { .. }
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm
)
})
.or_else(|| {
sections().find(|(source, _)| !matches!(source, ConfigLayerSource::Project { .. }))
});
selected
.map(|(_, section)| {
section.clone().try_into().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"enterprise IdP must be complete in one trusted config layer: {error}"
),
)
})
})
.transpose()
}
}
impl McpServerConfig {
pub fn oauth_idp(&self) -> Option<&McpServerIdpOAuthConfig> {
self.ema_registration().map(McpEmaRegistration::idp)
}
pub fn ema_registration(&self) -> Option<&McpEmaRegistration> {
self.oauth.as_ref()?.ema_registration.as_ref()
}
/// EMA never falls back to an unrelated bearer or executor-owned credential.
pub fn validate_ema_auth_transport(&self) -> Result<(), &'static str> {
if !self.is_local_environment() {
return Err("ema_auth requires a host-owned MCP connection");
}
let McpServerTransportConfig::StreamableHttp {
bearer_token_env_var,
http_headers,
env_http_headers,
http_headers_helper,
..
} = &self.transport
else {
return Err("ema_auth requires streamable HTTP");
};
if bearer_token_env_var.is_some()
|| http_headers
.as_ref()
.is_some_and(|headers| !headers.is_empty())
|| env_http_headers
.as_ref()
.is_some_and(|headers| !headers.is_empty())
|| http_headers_helper.is_some()
{
return Err("ema_auth cannot be combined with alternate HTTP credentials");
}
Ok(())
}
}
fn plugin_ema_registration(
config: &toml::Value,
plugin_name: &str,
server_name: &str,
) -> Option<PluginMcpServerEmaAuthConfig> {
config
.get("plugins")?
.get(plugin_name)?
.get("mcp_servers")?
.get(server_name)?
.get("ema_auth")?
.clone()
.try_into()
.ok()
}
fn has_trusted_atomic_registration(
stack: &ConfigLayerStack,
non_project: &toml::Value,
matches: impl Fn(&toml::Value) -> bool,
) -> bool {
matches(non_project)
&& stack
.layers_high_to_low()
.filter(|layer| !matches!(layer.name, ConfigLayerSource::Project { .. }))
.any(|layer| matches(&layer.config))
}
fn reenabled_over_non_project_denial(
effective_enabled: bool,
non_project: Option<&toml::Value>,
) -> bool {
effective_enabled
&& non_project
.and_then(|server| server.get("enabled"))
.and_then(toml::Value::as_bool)
== Some(false)
}
/// Projects cannot add, replace, or downgrade a non-project enterprise registration.
fn validate_ema_auth_sources(
stack: &ConfigLayerStack,
servers: &std::collections::HashMap<String, McpServerConfig>,
) -> io::Result<()> {
if stack.layers_high_to_low().next().is_none() {
return Ok(());
}
let mut non_project = toml::Value::Table(Default::default());
for layer in stack
.layers_low_to_high()
.filter(|layer| !matches!(layer.name, ConfigLayerSource::Project { .. }))
{
crate::merge_toml_values(&mut non_project, &layer.config);
}
for (name, effective) in servers {
let non_project_server = non_project
.get("mcp_servers")
.and_then(|servers| servers.get(name));
let inherited_ema = non_project_server
.and_then(|server| server.get("auth"))
.and_then(toml::Value::as_str)
== Some("ema_auth");
if !matches!(effective.auth, crate::McpServerAuth::EmaAuth) && !inherited_ema {
continue;
}
if reenabled_over_non_project_denial(effective.enabled, non_project_server) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("project configuration cannot re-enable enterprise MCP server `{name}`"),
));
}
let matches_effective_authorization = |server: &McpServerConfig| {
matches!(server.auth, crate::McpServerAuth::EmaAuth)
&& server.auth == effective.auth
&& server.transport == effective.transport
&& server.scopes == effective.scopes
&& server.oauth == effective.oauth
&& server.oauth_resource == effective.oauth_resource
};
if !has_trusted_atomic_registration(stack, &non_project, |config| {
config
.get("mcp_servers")
.and_then(|servers| servers.get(name))
.and_then(|server| server.clone().try_into::<McpServerConfig>().ok())
.is_some_and(|server| matches_effective_authorization(&server))
}) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"enterprise MCP server `{name}` must define its transport, authorization, scopes, and resource in one non-project config layer"
),
));
}
}
let effective_config = stack.effective_config();
let Some(plugins) = effective_config
.get("plugins")
.and_then(toml::Value::as_table)
else {
return Ok(());
};
for (plugin_name, plugin) in plugins {
let Some(plugin_servers) = plugin.get("mcp_servers").and_then(toml::Value::as_table) else {
continue;
};
for (server_name, server) in plugin_servers {
let Some(ema_auth) = server.get("ema_auth") else {
continue;
};
let non_project_server = non_project
.get("plugins")
.and_then(|plugins| plugins.get(plugin_name))
.and_then(|plugin| plugin.get("mcp_servers"))
.and_then(|servers| servers.get(server_name));
if reenabled_over_non_project_denial(
server.get("enabled").and_then(toml::Value::as_bool) != Some(false),
non_project_server,
) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"project configuration cannot re-enable enterprise MCP plugin `{plugin_name}` server `{server_name}`"
),
));
}
let effective = ema_auth
.clone()
.try_into::<PluginMcpServerEmaAuthConfig>()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
if !has_trusted_atomic_registration(stack, &non_project, |config| {
plugin_ema_registration(config, plugin_name, server_name)
.is_some_and(|registration| registration == effective)
}) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"enterprise MCP registration for plugin `{plugin_name}` server `{server_name}` must be defined in one non-project config layer"
),
));
}
}
}
Ok(())
}
/// Enabling XAA requires an explicit non-project setting or managed requirement.
fn validate_xaa_opt_in_source(stack: &ConfigLayerStack, xaa_enabled: bool) -> io::Result<()> {
if !xaa_enabled || stack.layers_high_to_low().next().is_none() {
return Ok(());
}
let required = stack
.requirements()
.feature_requirements
.as_ref()
.and_then(|requirements| requirements.value.entries.get(Feature::UseXaa.key()))
.copied();
let configured = stack
.layers_high_to_low()
.filter(|layer| !matches!(layer.name, ConfigLayerSource::Project { .. }))
.find_map(|layer| {
layer
.config
.get("features")?
.get(Feature::UseXaa.key())?
.as_bool()
});
if required.or(configured) == Some(true) {
return Ok(());
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"`[features].use_xaa = true` must be selected in a non-project config layer",
))
}
#[cfg(test)]
#[path = "mcp_ema_tests.rs"]
mod tests;
+113
View File
@@ -0,0 +1,113 @@
//! Enterprise registration provenance checks.
use super::*;
use crate::AbsolutePathBuf;
use crate::ConfigLayerEntry;
use crate::ConfigRequirements;
use crate::ConfigRequirementsToml;
use pretty_assertions::assert_eq;
fn stack(layers: Vec<(ConfigLayerSource, &str)>) -> ConfigLayerStack {
ConfigLayerStack::new(
layers
.into_iter()
.map(|(source, value)| ConfigLayerEntry::new(source, toml::from_str(value).unwrap()))
.collect(),
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.unwrap()
}
fn local_sources(name: &str) -> (ConfigLayerSource, ConfigLayerSource, ConfigLayerSource) {
let path = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join(name)).unwrap();
(
ConfigLayerSource::System { file: path.clone() },
ConfigLayerSource::User {
file: path.clone(),
profile: None,
},
ConfigLayerSource::Project {
dot_codex_folder: path,
},
)
}
#[test]
fn ema_profiles_and_auth_modes_preserve_non_project_authority() {
let (system, user, project) = local_sources("ema-config");
let trusted = "[mcp_enterprise_managed_auth.idp]\nissuer = 'https://idp.example'\nclient_id = 'enterprise'";
let replacement =
"[mcp_enterprise_managed_auth.idp]\nissuer = 'https://other.example'\nclient_id = 'other'";
let expected = McpEnterpriseManagedAuthConfig {
idp: McpServerIdpOAuthConfig {
issuer: "https://idp.example".into(),
client_id: "enterprise".into(),
},
};
for (layers, selected) in [
(
vec![
(system.clone(), trusted),
(user.clone(), replacement),
(project.clone(), replacement),
],
Some(expected.clone()),
),
(
vec![(user.clone(), trusted), (project.clone(), replacement)],
Some(expected.clone()),
),
(vec![(project, trusted)], None),
(
vec![
(system, trusted),
(
user,
"[mcp_enterprise_managed_auth.idp]\nclient_id = 'partial'",
),
],
Some(expected),
),
] {
assert_eq!(
McpEnterpriseManagedAuthConfig::from_config_layers(
&stack(layers),
/*fallback*/ None
)
.unwrap(),
selected
);
}
let incomplete = stack(vec![(
ConfigLayerSource::SessionFlags,
"[mcp_enterprise_managed_auth.idp]\nclient_id = 'partial'",
)]);
assert!(
McpEnterpriseManagedAuthConfig::from_config_layers(&incomplete, /*fallback*/ None).is_err()
);
let server: McpServerConfig = toml::from_str("url='https://resource.example'\nauth='ema_auth'\n[oauth.idp]\nissuer='https://other.example'\nclient_id='other'").unwrap();
assert_eq!(server.oauth_idp(), None);
}
#[test]
fn ema_rejects_alternate_credentials_and_executor_custody() {
for extra in [
"bearer_token_env_var='TOKEN'",
"http_headers.Authorization='secret'",
"http_headers.Accept='application/json'",
"env_http_headers.X-Key='TOKEN'",
"http_headers_helper='get-headers'",
"environment_id='remote'",
] {
let server: McpServerConfig = toml::from_str(&format!(
"url='https://resource.example'\nauth='ema_auth'\n{extra}"
))
.unwrap();
assert!(server.validate_ema_auth_transport().is_err(), "{extra}");
}
let server: McpServerConfig =
toml::from_str("url='https://resource.example'\nauth='ema_auth'\nhttp_headers={}").unwrap();
assert_eq!(server.validate_ema_auth_transport(), Ok(()));
}
+33 -12
View File
@@ -16,6 +16,7 @@ use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as SerdeError;
use crate::McpEmaRegistration;
use crate::RequirementSource;
/// Effective MCP environment id when config omits `environment_id`.
@@ -150,8 +151,8 @@ impl AsRef<str> for McpServerEnvVar {
}
}
/// OAuth client settings used when Codex launches an MCP OAuth flow.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
/// Client settings for MCP OAuth login or enterprise token exchange.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct McpServerOAuthConfig {
/// Explicit OAuth client identifier to present during authorization and token exchange.
@@ -165,13 +166,19 @@ pub struct McpServerOAuthConfig {
/// Fixed callback port that takes precedence over Codex's global OAuth callback port.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub callback_port: Option<u16>,
/// Expected resource authorization server issuer for EMA token exchange.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_server_issuer: Option<String>,
/// Host-resolved authorization; never accepted from a server or plugin declaration.
#[serde(skip)]
#[schemars(skip)]
pub ema_registration: Option<McpEmaRegistration>,
}
/// Authentication flow Codex attempts after resolving an HTTP MCP server's
/// configured bearer token and authorization headers, which always take
/// precedence. ChatGPT authentication falls back to stored OAuth credentials
/// when its session provider is unavailable; both modes ultimately fall back
/// to an unauthenticated connection.
/// Authentication flow for an HTTP MCP server. Explicit credentials take
/// precedence for OAuth and ChatGPT; EMA rejects alternate credentials and fallback.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum McpServerAuth {
@@ -185,6 +192,10 @@ pub enum McpServerAuth {
/// still fall back to stored OAuth credentials.
#[serde(rename = "chatgpt")]
ChatGpt,
/// Exchange an enterprise IdP refresh token for resource-specific authorization.
/// Alternate credentials and ordinary OAuth fallback are not permitted.
#[serde(rename = "ema_auth")]
EmaAuth,
}
impl McpServerAuth {
@@ -198,7 +209,7 @@ pub struct McpServerConfig {
#[serde(flatten)]
pub transport: McpServerTransportConfig,
/// Authentication flow to use when no configured authorization resolves.
/// Authentication flow, including an explicit no-fallback EMA mode.
#[serde(default, skip_serializing_if = "McpServerAuth::is_default")]
pub auth: McpServerAuth,
@@ -250,15 +261,15 @@ pub struct McpServerConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_tools: Option<Vec<String>>,
/// Optional OAuth scopes to request during MCP login.
/// Optional scopes requested during MCP login or EMA token exchange.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,
/// Optional OAuth client settings for MCP login.
/// Optional client settings for MCP login or EMA token exchange.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<McpServerOAuthConfig>,
/// Optional OAuth resource parameter to include during MCP login (RFC 8707).
/// Optional resource parameter for MCP login or EMA token exchange (RFC 8707).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth_resource: Option<String>,
@@ -490,10 +501,20 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
let environment_id =
environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string());
let auth = auth.unwrap_or_default();
if !matches!(auth, McpServerAuth::EmaAuth)
&& oauth
.as_ref()
.is_some_and(|oauth| oauth.authorization_server_issuer.is_some())
{
return Err(
"oauth.authorization_server_issuer requires auth = \"ema_auth\"".to_string(),
);
}
Ok(Self {
transport,
auth: auth.unwrap_or_default(),
auth,
environment_id,
startup_timeout_sec,
tool_timeout_sec,
+1
View File
@@ -376,6 +376,7 @@ fn deserialize_streamable_http_server_config_with_oauth_client_id() {
client_id: Some("eci-prd-pub-codex-123".to_string()),
callback_url: Some("http://127.0.0.1/callback/registered".to_string()),
callback_port: Some(9876),
..Default::default()
})
);
}
+22 -1
View File
@@ -3,6 +3,8 @@
// Note this file should generally be restricted to simple struct/enum
// definitions that do not contain business logic.
pub use crate::mcp_ema::McpEnterpriseManagedAuthConfig;
pub use crate::mcp_ema::McpServerIdpOAuthConfig;
pub use crate::mcp_types::AppToolApproval;
pub use crate::mcp_types::McpServerAuth;
pub use crate::mcp_types::McpServerConfig;
@@ -923,7 +925,7 @@ pub struct PluginConfig {
/// Policy settings for a plugin-provided MCP server.
///
/// This intentionally excludes transport settings: plugin manifests own how the
/// MCP server is launched, while user config owns enablement and tool policy.
/// MCP server is launched, while host config owns enablement, auth, and tool policy.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PluginMcpServerConfig {
@@ -931,6 +933,10 @@ pub struct PluginMcpServerConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Host-configured EMA registration; the plugin still owns its endpoint.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ema_auth: Option<PluginMcpServerEmaAuthConfig>,
/// Approval mode for tools in this server unless a tool override exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_tools_approval_mode: Option<AppToolApproval>,
@@ -952,6 +958,7 @@ impl Default for PluginMcpServerConfig {
fn default() -> Self {
Self {
enabled: true,
ema_auth: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
@@ -960,6 +967,20 @@ impl Default for PluginMcpServerConfig {
}
}
/// Resource registration applied through an existing per-plugin policy overlay.
/// The enterprise IdP is selected separately by trusted host configuration.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PluginMcpServerEmaAuthConfig {
/// Exact plugin endpoint approved by the host; never overrides the declaration.
pub url: String,
pub client_id: String,
pub authorization_server_issuer: String,
#[serde(default)]
pub scopes: Vec<String>,
pub resource: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MarketplaceConfig {
+6
View File
@@ -969,6 +969,9 @@ async fn load_plugin(
fn apply_plugin_mcp_server_policy(config: &mut McpServerConfig, policy: &PluginMcpServerConfig) {
config.enabled = policy.enabled;
if policy.ema_auth.is_some() {
config.auth = codex_config::McpServerAuth::EmaAuth;
}
if let Some(approval_mode) = policy.default_tools_approval_mode {
config.default_tools_approval_mode = Some(approval_mode);
}
@@ -1435,6 +1438,9 @@ pub fn apply_configured_plugin_mcp_server_policies(
) {
for (name, server) in servers {
if let Some(policy) = policies.get(name) {
if policy.ema_auth.is_some() {
server.auth = codex_config::McpServerAuth::EmaAuth;
}
let declared_approval_mode = server.default_tools_approval_mode.unwrap_or_default();
server.enabled &= policy.enabled;
@@ -999,6 +999,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
client_id: Some("client-id".to_string()),
callback_url: None,
callback_port: Some(3118),
..Default::default()
}),
oauth_resource: None,
tools: HashMap::new(),
+105 -3
View File
@@ -1073,6 +1073,9 @@
"use_linux_sandbox_bwrap": {
"type": "boolean"
},
"use_xaa": {
"type": "boolean"
},
"view_image": {
"type": "boolean"
},
@@ -2073,8 +2076,25 @@
},
"type": "object"
},
"McpEnterpriseManagedAuthConfig": {
"additionalProperties": false,
"properties": {
"idp": {
"allOf": [
{
"$ref": "#/definitions/McpServerIdpOAuthConfig"
}
],
"description": "Shared enterprise authorization, independent of Codex account credentials."
}
},
"required": [
"idp"
],
"type": "object"
},
"McpServerAuth": {
"description": "Authentication flow Codex attempts after resolving an HTTP MCP server's configured bearer token and authorization headers, which always take precedence. ChatGPT authentication falls back to stored OAuth credentials when its session provider is unavailable; both modes ultimately fall back to an unauthenticated connection.",
"description": "Authentication flow for an HTTP MCP server. Explicit credentials take precedence for OAuth and ChatGPT; EMA rejects alternate credentials and fallback.",
"oneOf": [
{
"description": "Use stored MCP OAuth credentials when available. Starting an OAuth login is a separate operation.",
@@ -2089,6 +2109,13 @@
"chatgpt"
],
"type": "string"
},
{
"description": "Exchange an enterprise IdP refresh token for resource-specific authorization. Alternate credentials and ordinary OAuth fallback are not permitted.",
"enum": [
"ema_auth"
],
"type": "string"
}
]
},
@@ -2114,10 +2141,32 @@
}
]
},
"McpServerIdpOAuthConfig": {
"additionalProperties": false,
"properties": {
"client_id": {
"description": "Public OAuth client registered with that enterprise IdP.",
"type": "string"
},
"issuer": {
"description": "Issuer used for enterprise OAuth discovery and identity validation.",
"type": "string"
}
},
"required": [
"client_id",
"issuer"
],
"type": "object"
},
"McpServerOAuthConfig": {
"additionalProperties": false,
"description": "OAuth client settings used when Codex launches an MCP OAuth flow.",
"description": "Client settings for MCP OAuth login or enterprise token exchange.",
"properties": {
"authorization_server_issuer": {
"description": "Expected resource authorization server issuer for EMA token exchange.",
"type": "string"
},
"callback_port": {
"description": "Fixed callback port that takes precedence over Codex's global OAuth callback port.",
"format": "uint16",
@@ -3167,7 +3216,7 @@
},
"PluginMcpServerConfig": {
"additionalProperties": false,
"description": "Policy settings for a plugin-provided MCP server.\n\nThis intentionally excludes transport settings: plugin manifests own how the MCP server is launched, while user config owns enablement and tool policy.",
"description": "Policy settings for a plugin-provided MCP server.\n\nThis intentionally excludes transport settings: plugin manifests own how the MCP server is launched, while host config owns enablement, auth, and tool policy.",
"properties": {
"default_tools_approval_mode": {
"allOf": [
@@ -3184,6 +3233,14 @@
},
"type": "array"
},
"ema_auth": {
"allOf": [
{
"$ref": "#/definitions/PluginMcpServerEmaAuthConfig"
}
],
"description": "Host-configured EMA registration; the plugin still owns its endpoint."
},
"enabled": {
"default": true,
"description": "When `false`, Codex skips initializing this plugin MCP server.",
@@ -3206,6 +3263,39 @@
},
"type": "object"
},
"PluginMcpServerEmaAuthConfig": {
"additionalProperties": false,
"description": "Resource registration applied through an existing per-plugin policy overlay. The enterprise IdP is selected separately by trusted host configuration.",
"properties": {
"authorization_server_issuer": {
"type": "string"
},
"client_id": {
"type": "string"
},
"resource": {
"type": "string"
},
"scopes": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
},
"url": {
"description": "Exact plugin endpoint approved by the host; never overrides the declaration.",
"type": "string"
}
},
"required": [
"authorization_server_issuer",
"client_id",
"resource",
"url"
],
"type": "object"
},
"ProfileTui": {
"additionalProperties": false,
"description": "TUI settings supported inside a named profile.",
@@ -6598,6 +6688,9 @@
"use_linux_sandbox_bwrap": {
"type": "boolean"
},
"use_xaa": {
"type": "boolean"
},
"view_image": {
"type": "boolean"
},
@@ -6740,6 +6833,15 @@
"description": "User-level marketplace entries keyed by marketplace name.",
"type": "object"
},
"mcp_enterprise_managed_auth": {
"allOf": [
{
"$ref": "#/definitions/McpEnterpriseManagedAuthConfig"
}
],
"default": null,
"description": "Trusted enterprise IdP shared by EMA-enabled MCP servers and plugins."
},
"mcp_oauth_callback_port": {
"description": "Optional fixed port for the local HTTP callback server used during MCP OAuth login. When unset, Codex will bind to an ephemeral port chosen by the OS.",
"format": "uint16",
+51
View File
@@ -6152,6 +6152,50 @@ async fn config_applies_managed_auth_store_and_chatgpt_base_url() -> std::io::Re
Ok(())
}
#[tokio::test]
async fn project_cannot_be_the_only_xaa_opt_in_source() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::Project {
dot_codex_folder: codex_home.path().join("project/.codex").abs(),
},
toml::toml! {
[features]
use_xaa = true
}
.into(),
)],
Default::default(),
Default::default(),
)?;
let cfg = config_layer_stack
.effective_config()
.try_into()
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
let error = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
cfg,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await
.expect_err("project-only XAA opt-in must fail");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert!(
error
.to_string()
.contains("must be selected in a non-project config layer")
);
Ok(())
}
#[tokio::test]
async fn config_resolves_default_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -7952,6 +7996,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
client_id: Some("eci-prd-pub-codex-123".to_string()),
callback_url: None,
callback_port: None,
..Default::default()
}),
oauth_resource: Some("https://resource.example.com".to_string()),
tools: HashMap::new(),
@@ -11126,6 +11171,7 @@ async fn feature_requirements_normalize_effective_feature_values() -> std::io::R
[features]
personality = true
shell_tool = false
use_xaa = true
"#,
),
)
@@ -11149,6 +11195,10 @@ shell_tool = false
#[tokio::test]
async fn feature_requirements_can_still_disable_unified_exec() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
"[features]\nuse_xaa = true\n",
)?;
let mut config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
@@ -11159,6 +11209,7 @@ async fn feature_requirements_can_still_disable_unified_exec() -> std::io::Resul
unified_exec = false
shell_tool = true
unified_exec_zsh_fork = false
use_xaa = false
"#,
),
)
@@ -101,8 +101,10 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> anyhow::Result<TomlTa
}
}
if matches!(&config.auth, McpServerAuth::ChatGpt) {
entry["auth"] = value("chatgpt");
match config.auth {
McpServerAuth::OAuth => {}
McpServerAuth::ChatGpt => entry["auth"] = value("chatgpt"),
McpServerAuth::EmaAuth => entry["auth"] = value("ema_auth"),
}
if !config.enabled {
entry["enabled"] = value(false);
@@ -159,6 +161,9 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> anyhow::Result<TomlTa
if let Some(callback_url) = &oauth.callback_url {
oauth_table["callback_url"] = value(callback_url.clone());
}
if let Some(issuer) = &oauth.authorization_server_issuer {
oauth_table["authorization_server_issuer"] = value(issuer.clone());
}
if let Some(callback_port) = oauth.callback_port {
oauth_table["callback_port"] = value(i64::from(callback_port));
}
+16
View File
@@ -1054,6 +1054,11 @@ fn blocking_replace_mcp_servers_round_trips() {
let codex_home = tmp.path();
let mut servers = BTreeMap::new();
servers.insert("ema".to_string(), serde_json::from_value(serde_json::json!({
"url": "https://ema.example/mcp", "auth": "ema_auth", "scopes": ["tools"],
"oauth_resource": "https://ema.example",
"oauth": {"client_id": "resource-client", "authorization_server_issuer": "https://as.example"}
})).expect("EMA server"));
servers.insert(
"stdio".to_string(),
McpServerConfig {
@@ -1121,6 +1126,7 @@ fn blocking_replace_mcp_servers_round_trips() {
client_id: Some("eci-prd-pub-codex-123".to_string()),
callback_url: Some("http://127.0.0.1/callback/example".to_string()),
callback_port: Some(9876),
..Default::default()
}),
oauth_resource: Some("https://resource.example.com".to_string()),
tools: HashMap::new(),
@@ -1135,6 +1141,16 @@ fn blocking_replace_mcp_servers_round_trips() {
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = "\
[mcp_servers.ema]
url = \"https://ema.example/mcp\"
auth = \"ema_auth\"
scopes = [\"tools\"]
oauth_resource = \"https://ema.example\"
[mcp_servers.ema.oauth]
client_id = \"resource-client\"
authorization_server_issuer = \"https://as.example\"
[mcp_servers.http]
url = \"https://example.com\"
bearer_token_env_var = \"TOKEN\"
+7
View File
@@ -18,6 +18,7 @@ use codex_config::ConfigRequirementsToml;
use codex_config::ConstrainedWithSource;
use codex_config::FeatureRequirementsToml;
use codex_config::ManagedAuthPolicy;
use codex_config::McpEnterpriseManagedAuthConfig;
use codex_config::McpServerRequirement;
use codex_config::PluginRequirementsToml;
use codex_config::ProfileV2Name;
@@ -3302,6 +3303,12 @@ impl Config {
feature_requirements,
&mut startup_warnings,
)?;
let _ = McpEnterpriseManagedAuthConfig::resolve(
&config_layer_stack,
cfg.mcp_enterprise_managed_auth.as_ref(),
&cfg.mcp_servers,
features.enabled(Feature::UseXaa),
)?;
let non_prefixed_mcp_tool_servers = if features.enabled(Feature::NonPrefixedMcpToolNames) {
cfg.features
.as_ref()
@@ -431,6 +431,7 @@ fn mcp_dependency_to_server_config(
client_id: None,
callback_url: None,
callback_port: Some(callback_port),
..Default::default()
}),
oauth_resource: None,
tools: HashMap::new(),
@@ -0,0 +1,67 @@
//! Exercises EMA registration ownership through real configuration layers.
use anyhow::Result;
use codex_config::LoaderOverrides;
use codex_config::McpServerAuth;
use codex_config::test_support::CloudConfigBundleFixture;
use codex_core::config::ConfigBuilder;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use test_case::test_case;
#[test_case("oauth"; "OAuth")]
#[test_case("chatgpt"; "ChatGPT")]
#[tokio::test]
async fn trusted_project_cannot_downgrade_enterprise_auth(project_auth: &str) -> Result<()> {
let home = tempdir()?;
let workspace = tempdir()?;
std::fs::create_dir_all(workspace.path().join(".git"))?;
std::fs::create_dir_all(workspace.path().join(".codex"))?;
set_project_trust_level(home.path(), workspace.path(), TrustLevel::Trusted)?;
let managed_config = r#"
[features]
use_xaa = true
[mcp_enterprise_managed_auth.idp]
issuer = "https://idp.example"
client_id = "idp-client"
[mcp_servers.enterprise]
url = "https://resource.example/mcp"
auth = "ema_auth"
scopes = ["files.read"]
oauth_resource = "https://resource.example/mcp"
[mcp_servers.enterprise.oauth]
client_id = "mcp-client"
"#;
let builder = ConfigBuilder::default()
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.codex_home(home.path().to_path_buf())
.fallback_cwd(Some(workspace.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::enterprise_config(managed_config.to_string())
.add_enterprise_requirement(
"[mcp_servers.enterprise.identity]\nurl = \"https://resource.example/mcp\"\n",
)
.into_loader(),
);
assert_eq!(
builder.clone().build().await?.mcp_servers.get()["enterprise"].auth,
McpServerAuth::EmaAuth,
);
std::fs::write(
workspace.path().join(".codex/config.toml"),
format!("[mcp_servers.enterprise]\nauth = {project_auth:?}\n"),
)?;
let error = builder
.build()
.await
.expect_err("a trusted project must not downgrade EMA before MCP startup");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
let message = format!("{error:#}");
assert!(
message.ends_with("one non-project config layer"),
"{message}"
);
Ok(())
}
@@ -419,6 +419,7 @@ async fn skill_mcp_dependency_oauth_uses_configured_http_client() -> Result<()>
client_id: None,
callback_url: None,
callback_port: Some(skill_callback_port),
..Default::default()
})
);
assert!(!servers.contains_key(DENIED_SERVER_NAME));
+1
View File
@@ -100,6 +100,7 @@ mod json_result;
mod live_cli;
mod mcp_auth_elicitation;
mod mcp_auth_refresh;
mod mcp_ema_config;
mod mcp_extension_protocol;
mod mcp_optional_startup_grace;
#[cfg(unix)]
+8
View File
@@ -215,6 +215,8 @@ pub enum Feature {
CodexAppsMcp20260728,
/// Let RMCP coordinate OAuth refresh through the configured credential store.
McpOAuthRefreshCoordination,
/// Enable enterprise refresh-token authorization for configured MCP resources.
UseXaa,
/// Removed compatibility flag for the legacy Apps MCP path override.
AppsMcpPathOverride,
/// Removed compatibility flag retained as a no-op now that tool_search is always enabled.
@@ -1325,6 +1327,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::UseXaa,
key: "use_xaa",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::AppsMcpPathOverride,
key: "apps_mcp_path_override",