Support confidential OAuth clients for enterprise-managed MCP auth (#56001)

## Why

Enterprise-managed MCP authentication previously required public OAuth clients at both the identity provider and the MCP authorization server, preventing use of registrations that require client secrets.

## What changed

- Accept separate `client_secret` and `token_endpoint_auth_method` settings for the enterprise IdP and MCP server OAuth registration. Support `client_secret_basic` and `client_secret_post`, defaulting to Basic when a secret is configured.
- Validate the registered method against server metadata and use it for enterprise login and both ID-JAG exchanges.
- Require a configured `oauth.authorization_server_issuer` for confidential MCP registrations and reject empty secrets or authentication methods without secrets.
- Require the account OAuth client to match the configured IdP client before reusing an account grant with a provisioned secret; otherwise use dedicated enterprise login.

## Testing

Extend configuration, login, exchange, and app-server lifecycle tests to cover confidential registrations, separate IdP and resource credentials, authentication method validation, and exclusion of client secrets from authorization URLs, stored login grants, and logs.

GitOrigin-RevId: b3be378c2b1bb67e6a3652c648e72fd2e21e956d
This commit is contained in:
jif
2026-10-21 22:30:35 +00:00
committed by copyberry
parent f496b09bd1
commit 6203846fb3
25 changed files with 627 additions and 90 deletions
+2
View File
@@ -226,6 +226,8 @@ fn plugin_host_root_is_retained_in_catalog_identity() {
fn ema_policy_survives_rebuilds_and_rebinds_materialized_servers() {
let idp = McpServerIdpOAuthConfig {
issuer: "https://idp.example".to_string(),
client_secret: None,
token_endpoint_auth_method: None,
client_id: "enterprise-client".to_string(),
};
let mut builder = ResolvedMcpCatalog::builder();
+4 -1
View File
@@ -1,5 +1,5 @@
//! Account-grant eligibility and registration-specific sign-in requirements.
//! The MCP service owns this state across threads; it retains no credentials.
//! The MCP service owns this state across threads; it retains registration policy but no user tokens.
use std::sync::Mutex;
@@ -66,6 +66,9 @@ impl EmaState {
pub(crate) fn account_grant_is_usable(tokens: &TokenData, idp: &McpServerIdpOAuthConfig) -> bool {
tokens.oauth_provenance.as_ref().is_some_and(|provenance| {
provenance.issuer == idp.issuer
// A provisioned secret belongs to the configured client, not another
// primary login client at the same IdP. Use dedicated login in that case.
&& (idp.client_secret.is_none() || provenance.client_id == idp.client_id)
&& tokens
.login_owner_id
.as_ref()
+5 -1
View File
@@ -181,7 +181,7 @@ impl EnterpriseTokenSource {
let tokens = grant.tokens();
// Metadata discovery must not block ordinary account refresh.
let identity = resolve_ema_account_idp_identity(
&idp.issuer,
idp,
client_id,
&tokens.id_token.raw_jwt,
tokens.refresh_token.clone(),
@@ -218,6 +218,8 @@ impl EnterpriseTokenSource {
resolve_ema_idp_identity(EmaIdpIdentityRequest {
issuer: &idp.issuer,
client_id,
client_secret: idp.client_secret.as_ref().map(|secret| secret.as_str()),
token_endpoint_auth_method: idp.token_endpoint_auth_method,
credentials: snapshot.acquire_current_ema_credentials().await?,
http_client: Arc::clone(&self.http_client),
redirect_mode: StreamableHttpRedirectMode::Legacy,
@@ -233,6 +235,8 @@ impl EnterpriseTokenSource {
resource: self.registration.resource(),
scopes: self.registration.scopes(),
mcp_client_id: self.registration.client_id(),
mcp_client_secret: self.registration.client_secret(),
token_endpoint_auth_method: self.registration.token_endpoint_auth_method(),
expected_authorization_server_issuer: self
.registration
.authorization_server_issuer(),
+2
View File
@@ -186,6 +186,8 @@ fn ema_catalog_allows_only_configured_servers_without_widening_policy() {
let mut config = test_mcp_config(PathBuf::new());
let idp = codex_config::McpServerIdpOAuthConfig {
issuer: "https://idp.example".into(),
client_secret: None,
token_endpoint_auth_method: None,
client_id: "enterprise-client".into(),
};
let deny_all = codex_protocol::mcp_policy::EnvironmentMcpPolicy {