diff --git a/codex-rs/rmcp-client/src/ema_auth_policy.rs b/codex-rs/rmcp-client/src/ema_auth_policy.rs new file mode 100644 index 0000000000..99d0688a70 --- /dev/null +++ b/codex-rs/rmcp-client/src/ema_auth_policy.rs @@ -0,0 +1,65 @@ +//! Credential-destination policy for enterprise MCP OAuth. + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use url::Host; +use url::Url; + +/// A sanitized enterprise-auth failure that callers may handle without parsing text. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum EmaAuthFailure { + #[error("invalid_grant")] + InvalidGrant { grant_source: EmaInvalidGrantSource }, + #[error("insufficient_user_authentication")] + InsufficientUserAuthentication, + #[error("enterprise identity requires authentication")] + ReauthenticationRequired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmaInvalidGrantSource { + EnterpriseIdentity, + ResourceAuthorization, +} + +pub(crate) fn safe_oauth_error_code(code: Option<&str>) -> &str { + code.filter(|code| { + matches!( + *code, + "invalid_request" + | "invalid_client" + | "invalid_grant" + | "invalid_scope" + | "invalid_target" + | "unauthorized_client" + | "unsupported_grant_type" + | "access_denied" + | "temporarily_unavailable" + | "server_error" + | "insufficient_user_authentication" + ) + }) + .unwrap_or("OAuth token request rejected") +} + +pub(crate) fn validate_ema_oauth_endpoint(endpoint: &str, description: &str) -> Result<()> { + let url = Url::parse(endpoint).with_context(|| format!("{description} is not a valid URL"))?; + validate_credential_destination(&url, description) +} + +fn validate_credential_destination(url: &Url, description: &str) -> Result<()> { + let loopback = match url.host() { + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(address)) => address.is_loopback(), + Some(Host::Ipv6(address)) => address.is_loopback(), + None => false, + }; + if url.scheme() != "https" && !(url.scheme() == "http" && loopback) { + bail!("{description} must use HTTPS or an HTTP loopback address"); + } + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + bail!("{description} contains disallowed credentials or a URL fragment"); + } + Ok(()) +} diff --git a/codex-rs/rmcp-client/src/ema_claims.rs b/codex-rs/rmcp-client/src/ema_claims.rs new file mode 100644 index 0000000000..f12c6f1ff3 --- /dev/null +++ b/codex-rs/rmcp-client/src/ema_claims.rs @@ -0,0 +1,200 @@ +//! Validate token routing and signed authorization before forwarding an ID-JAG. + +use std::collections::HashSet; +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::Deserialize; +use serde::de::DeserializeOwned; + +use crate::ema_exchange::EmaAccessToken; + +pub(crate) const ID_JAG_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:id-jag"; + +#[derive(Deserialize)] +struct JwtHeader { + alg: String, + typ: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum OAuthResource { + Single(String), + Multiple(Vec), +} + +impl OAuthResource { + fn is_exact(&self, expected: &str) -> bool { + match self { + Self::Single(value) => value == expected, + Self::Multiple(values) => values.as_slice() == [expected], + } + } +} + +fn signed_jwt(token: &str) -> Result<(JwtHeader, T)> { + let mut parts = token.split('.'); + let (Some(header), Some(payload), Some(signature), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + bail!("identity assertion is not a compact signed JWT"); + }; + if header.is_empty() || payload.is_empty() || signature.is_empty() { + bail!("identity assertion contains an empty JWT segment"); + } + let header: JwtHeader = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header)?) + .map_err(|_| anyhow!("invalid identity assertion JWT header"))?; + if header.alg.trim().is_empty() || header.alg.eq_ignore_ascii_case("none") { + bail!("identity assertion is unsigned"); + } + let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?) + .map_err(|_| anyhow!("invalid identity assertion JWT claims"))?; + Ok((header, claims)) +} + +#[derive(Deserialize)] +struct IdJagClaims { + iss: String, + sub: String, + aud: OAuthResource, + client_id: String, + jti: String, + exp: u64, + iat: u64, + resource: OAuthResource, + scope: Option, +} + +pub(crate) struct IdJagBinding<'a> { + pub issuer: &'a str, + pub audience: &'a str, + pub client_id: &'a str, + pub resource: &'a str, + /// Empty means the scope parameter was omitted, not an empty authorization ceiling. + pub requested_scopes: &'a HashSet<&'a str>, +} + +#[derive(Deserialize)] +pub(crate) struct IdJagResponse { + pub access_token: String, + issued_token_type: String, + token_type: String, + resource: Option, + scope: Option, + refresh_token: Option, +} + +impl IdJagResponse { + pub(crate) fn validate(&self, binding: IdJagBinding<'_>) -> Result> { + if self.issued_token_type != ID_JAG_TOKEN_TYPE + || self.token_type != "N_A" + || self.refresh_token.is_some() + { + bail!("enterprise IdP returned an unsupported ID-JAG token type or refresh token"); + } + let (header, claims): (_, IdJagClaims) = signed_jwt(&self.access_token)?; + if header.typ.as_deref() != Some("oauth-id-jag+jwt") + || claims.iss != binding.issuer + || !claims.aud.is_exact(binding.audience) + || claims.client_id != binding.client_id + || claims.sub.trim().is_empty() + || claims.jti.trim().is_empty() + { + bail!("ID-JAG type, issuer, audience, client, subject, or JWT ID is invalid"); + } + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + if claims.exp <= now || claims.iat > now.saturating_add(60) { + bail!("enterprise IdP returned an expired or future-issued ID-JAG"); + } + if !claims.resource.is_exact(binding.resource) + || self + .resource + .as_ref() + .is_some_and(|value| !value.is_exact(binding.resource)) + { + bail!("ID-JAG must authorize exactly the configured MCP resource"); + } + let granted = match claims.scope.as_deref() { + Some(scope) => parse_scope(scope)?, + None if binding.requested_scopes.is_empty() => HashSet::new(), + None => bail!("ID-JAG is missing the requested scope authorization"), + }; + if !binding.requested_scopes.is_empty() && !granted.is_subset(binding.requested_scopes) { + bail!("ID-JAG contains a scope outside the enterprise authorization request"); + } + match self.scope.as_deref() { + Some(scope) if parse_scope(scope)? != granted => { + bail!("enterprise IdP token response scope does not match the signed ID-JAG") + } + None if !binding.requested_scopes.is_empty() + && granted != *binding.requested_scopes => + { + bail!("enterprise IdP token response omitted its narrowed scope") + } + _ => {} + } + Ok(granted.into_iter().map(str::to_string).collect()) + } +} + +fn parse_scope(scope: &str) -> Result> { + let scopes = scope.split_ascii_whitespace().collect::>(); + if scopes.is_empty() || scopes.len() != scope.split_ascii_whitespace().count() { + bail!("enterprise authorization contains malformed or duplicate scopes"); + } + Ok(scopes) +} + +#[derive(Deserialize)] +pub(crate) struct McpAccessTokenResponse { + access_token: String, + token_type: String, + expires_in: Option, + resource: Option, + scope: Option, + refresh_token: Option, +} + +impl McpAccessTokenResponse { + pub(crate) fn validate( + self, + resource: &str, + id_jag_scopes: &HashSet, + ) -> Result { + if !self.token_type.eq_ignore_ascii_case("bearer") || self.access_token.trim().is_empty() { + bail!("MCP authorization server returned an invalid bearer token"); + } + if self.refresh_token.is_some() || self.expires_in == Some(0) { + bail!("MCP authorization server returned a refresh token or zero token lifetime"); + } + // The stable EMA response does not require the resource to be echoed; + // when present, it must agree with the resource bound in the ID-JAG. + if self + .resource + .as_ref() + .is_some_and(|returned| !returned.is_exact(resource)) + { + bail!("MCP access token must authorize exactly the configured MCP resource"); + } + // RFC 6749 defines an omitted scope as unchanged from the request. Here + // that authority is the scope carried by the validated ID-JAG. + if let Some(scope) = self.scope.as_deref() + && !parse_scope(scope)? + .iter() + .all(|scope| id_jag_scopes.contains(*scope)) + { + bail!("MCP authorization server granted a scope outside the ID-JAG authorization"); + } + Ok(EmaAccessToken { + access_token: self.access_token, + expires_in: self.expires_in.map(Duration::from_secs), + }) + } +} diff --git a/codex-rs/rmcp-client/src/ema_exchange.rs b/codex-rs/rmcp-client/src/ema_exchange.rs new file mode 100644 index 0000000000..7b6131653a --- /dev/null +++ b/codex-rs/rmcp-client/src/ema_exchange.rs @@ -0,0 +1,219 @@ +//! Non-interactive ID-JAG exchange against explicitly trusted OAuth endpoints. + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use codex_exec_server::HttpClient; +use rmcp::transport::auth::OAuthHttpRedirectPolicy; +use serde::Deserialize; +use serde::de::DeserializeOwned; + +use crate::ema_auth_policy::EmaAuthFailure; +use crate::ema_auth_policy::EmaInvalidGrantSource; +use crate::ema_auth_policy::safe_oauth_error_code; +use crate::ema_auth_policy::validate_ema_oauth_endpoint; +use crate::ema_claims::ID_JAG_TOKEN_TYPE; +use crate::ema_claims::IdJagBinding; +use crate::ema_claims::IdJagResponse; +use crate::ema_claims::McpAccessTokenResponse; +use crate::http_client_adapter::StreamableHttpRedirectMode; +use crate::oauth_http_client::OAuthHttpClientAdapter; +use crate::utils::build_default_headers; + +pub(crate) const TOKEN_EXCHANGE_GRANT_TYPE: &str = + "urn:ietf:params:oauth:grant-type:token-exchange"; +pub(crate) const JWT_BEARER_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + +/// A resource-bound bearer and its server-reported lifetime, with redacted diagnostics. +#[derive(Clone, PartialEq, Eq)] +pub struct EmaAccessToken { + pub access_token: String, + pub expires_in: Option, +} + +impl std::fmt::Debug for EmaAccessToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EmaAccessToken") + .field("access_token", &"[REDACTED]") + .field("expires_in", &self.expires_in) + .finish() + } +} + +/// The caller supplies trusted authorization-server metadata and an IdP credential. +/// This primitive does not perform resource discovery or interactive login. +pub struct EmaIdJagExchangeRequest<'a> { + pub resource: &'a str, + pub scopes: &'a [String], + pub mcp_client_id: &'a str, + pub authorization_server_issuer: &'a str, + pub authorization_server_token_endpoint: &'a str, + pub idp_token_endpoint: &'a str, + pub idp_issuer: &'a str, + pub idp_client_id: &'a str, + pub refresh_token: String, + pub idp_http_client: Arc, + pub resource_http_client: Arc, +} + +/// Exchanges an enterprise IdP credential for a resource-bound MCP bearer token. +pub async fn exchange_id_jag(request: EmaIdJagExchangeRequest<'_>) -> Result { + for (endpoint, description) in [ + (request.resource, "enterprise MCP resource"), + ( + request.authorization_server_issuer, + "MCP authorization server issuer", + ), + ( + request.authorization_server_token_endpoint, + "MCP token endpoint", + ), + (request.idp_issuer, "enterprise IdP issuer"), + (request.idp_token_endpoint, "enterprise IdP token endpoint"), + ] { + validate_ema_oauth_endpoint(endpoint, description)?; + } + if request.authorization_server_issuer == request.idp_issuer { + bail!("enterprise IdP and MCP authorization server issuers must be different for ID-JAG"); + } + if request.mcp_client_id.trim().is_empty() || request.idp_client_id.trim().is_empty() { + bail!("enterprise authorization requires the registered IdP and MCP client IDs"); + } + if request.refresh_token.trim().is_empty() { + bail!("enterprise IdP refresh token must not be empty"); + } + let requested_scopes = request + .scopes + .iter() + .map(String::as_str) + .collect::>(); + if requested_scopes.len() != request.scopes.len() + || request + .scopes + .iter() + .any(|scope| scope.is_empty() || scope.chars().any(char::is_whitespace)) + { + bail!("enterprise MCP authorization scopes must be distinct, non-empty scope tokens"); + } + let scope = (!request.scopes.is_empty()).then(|| request.scopes.join(" ")); + let mut params = vec![ + ("grant_type", TOKEN_EXCHANGE_GRANT_TYPE), + ("requested_token_type", ID_JAG_TOKEN_TYPE), + ("audience", request.authorization_server_issuer), + ("resource", request.resource), + ("subject_token", request.refresh_token.as_str()), + ( + "subject_token_type", + "urn:ietf:params:oauth:token-type:refresh_token", + ), + ]; + if let Some(scope) = scope.as_deref() { + params.push(("scope", scope)); + } + let id_jag: IdJagResponse = post_form( + &request.idp_http_client, + request.idp_token_endpoint, + ¶ms, + request.idp_client_id, + EmaInvalidGrantSource::EnterpriseIdentity, + "enterprise IdP ID-JAG exchange", + ) + .await?; + let granted_scopes = id_jag.validate(IdJagBinding { + issuer: request.idp_issuer, + audience: request.authorization_server_issuer, + client_id: request.mcp_client_id, + resource: request.resource, + requested_scopes: &requested_scopes, + })?; + // Only the signed assertion carries authority to the Resource AS. Repeating + // the requested resource or scopes could undo enterprise policy narrowing. + let access_token: McpAccessTokenResponse = post_form( + &request.resource_http_client, + request.authorization_server_token_endpoint, + &[ + ("grant_type", JWT_BEARER_GRANT_TYPE), + ("assertion", id_jag.access_token.as_str()), + ], + request.mcp_client_id, + EmaInvalidGrantSource::ResourceAuthorization, + "MCP JWT bearer exchange", + ) + .await?; + access_token.validate(request.resource, &granted_scopes) +} + +#[derive(Deserialize)] +struct OAuthErrorResponse { + error: Option, +} + +pub(crate) async fn post_form( + http_client: &Arc, + url: &str, + params: &[(&str, &str)], + client_id: &str, + invalid_grant_source: EmaInvalidGrantSource, + operation: &str, +) -> Result { + let client = OAuthHttpClientAdapter::new_with_redirect_mode( + Arc::clone(http_client), + build_default_headers(/*http_headers*/ None, /*env_http_headers*/ None)?, + url, + /*has_configured_headers*/ false, + StreamableHttpRedirectMode::Legacy, + )?; + let body = { + let mut form = url::form_urlencoded::Serializer::new(String::new()); + form.extend_pairs(params.iter().copied()); + form.append_pair("client_id", client_id); + form.finish().into_bytes() + }; + let builder = oauth2::http::Request::builder() + .method("POST") + .uri(url) + .header("content-type", "application/x-www-form-urlencoded") + .header("accept", "application/json"); + let response = client + .execute_request( + builder.body(body)?, + OAuthHttpRedirectPolicy::Stop, + Some(Duration::from_secs(30)), + ) + .await + .map_err(|error| anyhow!("{operation} request failed: {error}"))?; + if !response.status().is_success() { + let error = serde_json::from_slice::(response.body()).ok(); + // Provider-controlled text may reflect the submitted assertion or secret. + // Only known OAuth codes may reach callers. + let code = safe_oauth_error_code(error.as_ref().and_then(|error| error.error.as_deref())); + if code == "invalid_grant" { + return Err(anyhow::Error::new(EmaAuthFailure::InvalidGrant { + grant_source: invalid_grant_source, + }) + .context(format!( + "{operation} returned HTTP {}: invalid_grant", + response.status() + ))); + } + if code == "insufficient_user_authentication" { + return Err( + anyhow::Error::new(EmaAuthFailure::InsufficientUserAuthentication).context( + format!("{operation} returned HTTP {}: {code}", response.status()), + ), + ); + } + bail!("{operation} returned HTTP {}: {code}", response.status()); + } + serde_json::from_slice(response.body()) + .map_err(|_| anyhow!("failed to parse {operation} response")) +} + +#[cfg(test)] +#[path = "ema_exchange_tests.rs"] +mod tests; diff --git a/codex-rs/rmcp-client/src/ema_exchange_tests.rs b/codex-rs/rmcp-client/src/ema_exchange_tests.rs new file mode 100644 index 0000000000..f23bdb75c3 --- /dev/null +++ b/codex-rs/rmcp-client/src/ema_exchange_tests.rs @@ -0,0 +1,420 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_exec_server::RouteAwareHttpClient; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; +use futures::FutureExt; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::*; + +fn http_client() -> Arc { + Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new( + OutboundProxyPolicy::ReqwestDefault, + ))) +} + +fn unique_form_fields(body: &[u8]) -> HashMap { + let pairs = url::form_urlencoded::parse(body) + .into_owned() + .collect::>(); + let fields = pairs.iter().cloned().collect::>(); + assert_eq!( + pairs.len(), + fields.len(), + "OAuth form must not contain duplicate fields" + ); + fields +} + +fn jwt(claims: &Value) -> String { + format!( + "{}.{}.signature", + URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"oauth-id-jag+jwt"}"#), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("serialize claims")) + ) +} + +fn claims(issuer: &str, audience: &str, resource: &str) -> Value { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("current time") + .as_secs(); + json!({"iss":issuer,"aud":audience,"sub":"user","client_id":"mcp-client", + "jti":"unique-jag","iat":now,"exp":now + 3600,"resource":resource,"scope":"files.read"}) +} + +fn jag_response(claims: &Value) -> Value { + let mut response = json!({"access_token":jwt(claims),"issued_token_type":ID_JAG_TOKEN_TYPE, + "token_type":"N_A","resource":claims["resource"]}); + if let Some(scope) = claims.get("scope") { + response["scope"] = scope.clone(); + } + response +} + +fn token_response() -> Value { + json!({"access_token":"resource-token","token_type":"Bearer","expires_in":300}) +} + +#[tokio::test] +async fn public_client_round_trip_preserves_signed_narrowing() -> Result<()> { + let requested_scopes = ["files.read".to_string(), "files.write".to_string()]; + let client = http_client(); + for (scopes, echo_scope, refresh_token) in [ + (requested_scopes.as_slice(), true, "opaque-refresh-token"), + (&[], true, "opaque-refresh-token"), + (&[], false, "opaque-refresh-token"), + (requested_scopes.as_slice(), true, ""), + (requested_scopes.as_slice(), true, " \t"), + ] { + let server = MockServer::start().await; + let issuer = format!("{}/idp", server.uri()); + let audience = format!("{}/as", server.uri()); + let resource = format!("{}/mcp", server.uri()); + let mut jag = jag_response(&claims(&issuer, &audience, &resource)); + if !echo_scope { + jag.as_object_mut() + .expect("ID-JAG response") + .remove("scope"); + } + let valid = !refresh_token.trim().is_empty(); + for (endpoint, response) in [("/idp/token", jag.clone()), ("/as/token", token_response())] { + Mock::given(method("POST")) + .and(path(endpoint)) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .expect(u64::from(valid)) + .mount(&server) + .await; + } + let expected_subject = refresh_token.to_string(); + let result = exchange_id_jag(EmaIdJagExchangeRequest { + resource: &resource, + scopes, + mcp_client_id: "mcp-client", + authorization_server_issuer: &audience, + authorization_server_token_endpoint: &format!("{audience}/token"), + idp_token_endpoint: &format!("{issuer}/token"), + idp_issuer: &issuer, + idp_client_id: "idp-client", + refresh_token: refresh_token.to_string(), + idp_http_client: Arc::clone(&client), + resource_http_client: Arc::clone(&client), + }) + .boxed() + .await; + if !valid { + assert!(result.is_err(), "invalid subject must fail before HTTP"); + assert!( + server + .received_requests() + .await + .expect("requests") + .is_empty() + ); + continue; + } + assert_eq!( + result?, + EmaAccessToken { + access_token: "resource-token".to_string(), + expires_in: Some(Duration::from_secs(300)), + } + ); + let requests = server.received_requests().await.expect("requests"); + assert_eq!(requests.len(), 2); + assert!( + requests + .iter() + .all(|request| request.headers.get("authorization").is_none()) + ); + let mut forms = requests + .iter() + .map(|request| unique_form_fields(&request.body)) + .collect::>(); + assert_eq!( + forms[0].remove("scope"), + (!scopes.is_empty()).then(|| scopes.join(" ")) + ); + assert_eq!( + forms[0], + HashMap::from([ + ( + "grant_type".to_string(), + TOKEN_EXCHANGE_GRANT_TYPE.to_string() + ), + ( + "requested_token_type".to_string(), + ID_JAG_TOKEN_TYPE.to_string(), + ), + ("subject_token".to_string(), expected_subject), + ( + "subject_token_type".to_string(), + "urn:ietf:params:oauth:token-type:refresh_token".to_string(), + ), + ("audience".to_string(), audience.clone()), + ("resource".to_string(), resource.clone()), + ("client_id".to_string(), "idp-client".to_string()), + ]) + ); + assert_eq!( + forms[1], + HashMap::from([ + ("grant_type".to_string(), JWT_BEARER_GRANT_TYPE.to_string()), + ( + "assertion".to_string(), + jag["access_token"].as_str().expect("JAG").to_string() + ), + ("client_id".to_string(), "mcp-client".to_string()), + ]) + ); + } + Ok(()) +} + +#[test] +fn signed_claims_and_resource_tokens_cannot_widen_authority() -> Result<()> { + let requested = HashSet::from(["files.read", "files.write"]); + let original = claims( + "https://idp.example", + "https://as.example", + "https://mcp.example", + ); + let binding = || IdJagBinding { + issuer: "https://idp.example", + audience: "https://as.example", + client_id: "mcp-client", + resource: "https://mcp.example", + requested_scopes: &requested, + }; + let valid: IdJagResponse = serde_json::from_value(jag_response(&original))?; + let granted = valid.validate(binding())?; + assert_eq!(granted, HashSet::from(["files.read".to_string()])); + for (requested_scopes, signed_scope, response_scope, valid) in [ + ("", Some("files.read"), Some("files.read"), true), + ("", Some("files.read"), None, true), + ("", None, None, true), + ("files.read", Some("files.read"), None, true), + ("files.read files.write", Some("files.read"), None, false), + ( + "files.read", + Some("files.read files.write"), + Some("files.read files.write"), + false, + ), + ("files.read", None, None, false), + ("", Some("files.read"), Some("files.write"), false), + ("", Some(" \t"), None, false), + ("", Some("files.read files.read"), Some("files.read"), false), + ("", Some("files.read"), Some(" \t"), false), + ("", Some("files.read"), Some("files.read files.read"), false), + ] { + let requested_scopes = requested_scopes.split_ascii_whitespace().collect(); + let mut scoped_claims = original.clone(); + scoped_claims + .as_object_mut() + .expect("ID-JAG claims") + .remove("scope"); + if let Some(scope) = signed_scope { + scoped_claims["scope"] = json!(scope); + } + let mut response = jag_response(&scoped_claims); + response + .as_object_mut() + .expect("ID-JAG response") + .remove("scope"); + if let Some(scope) = response_scope { + response["scope"] = json!(scope); + } + let response: IdJagResponse = serde_json::from_value(response)?; + let result = response.validate(IdJagBinding { + requested_scopes: &requested_scopes, + ..binding() + }); + assert_eq!( + result.is_ok(), + valid, + "requested {requested_scopes:?}, signed {signed_scope:?}, response {response_scope:?}" + ); + if let Ok(granted) = result { + assert_eq!( + granted, + signed_scope + .into_iter() + .map(str::to_string) + .collect::>() + ); + for (scope, valid_token) in [ + (None, true), + (Some("files.read"), signed_scope.is_some()), + (Some("files.read files.write"), false), + ] { + let mut response = token_response(); + if let Some(scope) = scope { + response["scope"] = json!(scope); + } + let response: McpAccessTokenResponse = serde_json::from_value(response)?; + assert_eq!( + response.validate("https://mcp.example", &granted).is_ok(), + valid_token, + "ID-JAG {signed_scope:?}, bearer {scope:?}" + ); + } + } + } + let mut rotated = jag_response(&original); + rotated["refresh_token"] = json!("unsupported-jag-refresh-token"); + let response: IdJagResponse = serde_json::from_value(rotated)?; + assert!(response.validate(binding()).is_err()); + for header in [ + json!({"alg": "ES256", "typ": "JWT"}), + json!({"alg": "ES256"}), + json!({"alg": "none", "typ": "oauth-id-jag+jwt"}), + ] { + let mut changed = jag_response(&original); + changed["access_token"] = json!(format!( + "{}.{}.signature", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&original)?), + )); + let response: IdJagResponse = serde_json::from_value(changed)?; + assert!( + response.validate(binding()).is_err(), + "accepted invalid ID-JAG header {header}" + ); + } + for (field, value) in [ + ("iss", json!("https://attacker.example")), + ("aud", json!("https://attacker.example")), + ("client_id", json!("other-client")), + ("sub", json!("")), + ("jti", json!("")), + ("exp", json!(0)), + ("iat", json!(u64::MAX)), + ("scope", json!("files.admin")), + ( + "resource", + json!(["https://mcp.example", "https://other.example"]), + ), + ] { + let mut changed = original.clone(); + changed[field] = value; + let response: IdJagResponse = serde_json::from_value(jag_response(&changed))?; + assert!( + response.validate(binding()).is_err(), + "accepted changed {field}" + ); + } + for (field, value) in [ + ("scope", json!("files.read files.write")), + ("scope", json!("files.read files.read")), + ("resource", json!("https://other.example")), + ("expires_in", json!(0)), + ("refresh_token", json!("refresh")), + ("token_type", json!("N_A")), + ("access_token", json!("")), + ] { + let mut changed = token_response(); + changed[field] = value; + let response: McpAccessTokenResponse = serde_json::from_value(changed)?; + assert!( + response.validate("https://mcp.example", &granted).is_err(), + "accepted changed {field}" + ); + } + let mut explicit_binding = token_response(); + explicit_binding["resource"] = json!("https://mcp.example"); + explicit_binding["scope"] = json!("files.read"); + let response: McpAccessTokenResponse = serde_json::from_value(explicit_binding)?; + assert_eq!( + response.validate("https://mcp.example", &granted)?, + EmaAccessToken { + access_token: "resource-token".to_string(), + expires_in: Some(Duration::from_secs(300)), + } + ); + Ok(()) +} + +#[tokio::test] +async fn provider_errors_cannot_reflect_credentials() { + const SENTINEL: &str = "secret-assertion-sentinel"; + for (code, expected) in [ + (SENTINEL, "OAuth token request rejected"), + ("invalid_grant", "invalid_grant"), + ( + "insufficient_user_authentication", + "insufficient_user_authentication", + ), + ] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error":code,"error_description":SENTINEL, + }))) + .mount(&server) + .await; + let error = post_form::( + &http_client(), + &format!("{}/token", server.uri()), + &[("subject_token", SENTINEL)], + "test-client", + EmaInvalidGrantSource::EnterpriseIdentity, + "test token exchange", + ) + .await + .expect_err("provider error should fail"); + match code { + "invalid_grant" => assert_eq!( + error.downcast_ref::(), + Some(&EmaAuthFailure::InvalidGrant { + grant_source: EmaInvalidGrantSource::EnterpriseIdentity, + }) + ), + "insufficient_user_authentication" => assert_eq!( + error.downcast_ref::(), + Some(&EmaAuthFailure::InsufficientUserAuthentication) + ), + _ => assert_eq!(error.downcast_ref::(), None), + } + let error = error.to_string(); + assert!(!error.contains(SENTINEL), "provider reflected a credential"); + assert!(error.ends_with(expected), "{error}"); + } + let server = MockServer::start().await; + let mut malformed = token_response(); + malformed["expires_in"] = json!(SENTINEL); + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(malformed)) + .mount(&server) + .await; + let error = post_form::( + &http_client(), + &format!("{}/token", server.uri()), + &[], + "test-client", + EmaInvalidGrantSource::EnterpriseIdentity, + "test token exchange", + ) + .await + .err() + .expect("malformed response should fail"); + assert!( + !format!("{error:#}").contains(SENTINEL), + "parser reflected a credential" + ); +} diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index b5184ba60b..623b519abd 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -1,5 +1,8 @@ mod auth_status; mod elicitation_client_service; +mod ema_auth_policy; +mod ema_claims; +mod ema_exchange; mod event_notification_transport; mod executor_process_transport; mod http_client_adapter; @@ -28,6 +31,11 @@ pub use auth_status::determine_streamable_http_auth_status; pub use auth_status::determine_streamable_http_auth_status_from_credentials; pub use auth_status::discover_streamable_http_oauth; pub use codex_protocol::protocol::McpAuthStatus; +pub use ema_auth_policy::EmaAuthFailure; +pub use ema_auth_policy::EmaInvalidGrantSource; +pub use ema_exchange::EmaAccessToken; +pub use ema_exchange::EmaIdJagExchangeRequest; +pub use ema_exchange::exchange_id_jag; pub use event_notification_transport::EventNotificationReceiver; pub use http_client_adapter::StreamableHttpRedirectMode; pub use http_headers::with_http_headers_helper; diff --git a/codex-rs/rmcp-client/src/oauth_http_client.rs b/codex-rs/rmcp-client/src/oauth_http_client.rs index d9838bd4a5..14c70aba1b 100644 --- a/codex-rs/rmcp-client/src/oauth_http_client.rs +++ b/codex-rs/rmcp-client/src/oauth_http_client.rs @@ -120,7 +120,7 @@ impl OAuthHttpClientAdapter { }) } - async fn execute_request( + pub(crate) async fn execute_request( &self, request: HttpRequest, redirect_policy: OAuthHttpRedirectPolicy,