Add enterprise ID-JAG exchange for MCP OAuth (#40722)

## What changed

- Add a non-interactive two-step exchange that obtains an ID-JAG from an
  enterprise identity provider and trades it for a resource-bound MCP bearer
  token.
- Validate trusted endpoint URLs, request inputs, ID-JAG claims, resource and
  scope bindings, and token responses before credentials are forwarded or a
  bearer token is returned.
- Expose structured authentication failures while redacting credentials and
  provider-controlled error details from diagnostics.

## Testing

- Cover successful exchanges, signed scope narrowing, invalid claims and token
  responses, request validation, and error redaction.

GitOrigin-RevId: d716e0e1c2dc6b230cecbc0e9cc09afeee81d599
This commit is contained in:
Nick Steele
2026-08-25 22:03:58 +00:00
committed by copyberry
parent d6174a8798
commit f6805328c4
6 changed files with 913 additions and 1 deletions

View File

@@ -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(())
}

View File

@@ -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<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum OAuthResource {
Single(String),
Multiple(Vec<String>),
}
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<T: DeserializeOwned>(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<String>,
}
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<OAuthResource>,
scope: Option<String>,
refresh_token: Option<String>,
}
impl IdJagResponse {
pub(crate) fn validate(&self, binding: IdJagBinding<'_>) -> Result<HashSet<String>> {
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<HashSet<&str>> {
let scopes = scope.split_ascii_whitespace().collect::<HashSet<_>>();
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<u64>,
resource: Option<OAuthResource>,
scope: Option<String>,
refresh_token: Option<String>,
}
impl McpAccessTokenResponse {
pub(crate) fn validate(
self,
resource: &str,
id_jag_scopes: &HashSet<String>,
) -> Result<EmaAccessToken> {
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),
})
}
}

View File

@@ -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<Duration>,
}
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<dyn HttpClient>,
pub resource_http_client: Arc<dyn HttpClient>,
}
/// Exchanges an enterprise IdP credential for a resource-bound MCP bearer token.
pub async fn exchange_id_jag(request: EmaIdJagExchangeRequest<'_>) -> Result<EmaAccessToken> {
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::<HashSet<_>>();
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,
&params,
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<String>,
}
pub(crate) async fn post_form<T: DeserializeOwned>(
http_client: &Arc<dyn HttpClient>,
url: &str,
params: &[(&str, &str)],
client_id: &str,
invalid_grant_source: EmaInvalidGrantSource,
operation: &str,
) -> Result<T> {
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::<OAuthErrorResponse>(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;

View File

@@ -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<dyn HttpClient> {
Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new(
OutboundProxyPolicy::ReqwestDefault,
)))
}
fn unique_form_fields(body: &[u8]) -> HashMap<String, String> {
let pairs = url::form_urlencoded::parse(body)
.into_owned()
.collect::<Vec<_>>();
let fields = pairs.iter().cloned().collect::<HashMap<_, _>>();
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::<Vec<_>>();
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::<HashSet<_>>()
);
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::<Value>(
&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::<EmaAuthFailure>(),
Some(&EmaAuthFailure::InvalidGrant {
grant_source: EmaInvalidGrantSource::EnterpriseIdentity,
})
),
"insufficient_user_authentication" => assert_eq!(
error.downcast_ref::<EmaAuthFailure>(),
Some(&EmaAuthFailure::InsufficientUserAuthentication)
),
_ => assert_eq!(error.downcast_ref::<EmaAuthFailure>(), 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::<McpAccessTokenResponse>(
&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"
);
}

View File

@@ -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;

View File

@@ -120,7 +120,7 @@ impl OAuthHttpClientAdapter {
})
}
async fn execute_request(
pub(crate) async fn execute_request(
&self,
request: HttpRequest,
redirect_policy: OAuthHttpRedirectPolicy,