Use the rmcp SDK for enterprise-managed token exchanges (#49473)

## What changed

Upgrade `rmcp` to a pinned Git revision of version `3.3.0` and enable `auth-enterprise-managed`. Delegate the two-stage ID-JAG exchange and token validation to the SDK, replacing the local implementation while retaining Codex HTTP routing and credential lifecycle policy.

Map SDK errors by exchange stage so `invalid_grant` distinguishes enterprise identity failures from resource authorization failures, and preserve handling of `insufficient_user_authentication`.

## Testing

Update exchange tests to verify rejection of unsupported `authorization_details` and malformed signed scopes. Exercise errors from both token endpoints to check credential policy, prevent credential reflection in diagnostics, and ensure rejected grants are not retried.

GitOrigin-RevId: a56034aa3fd70002754ea552fd2da70bdcd85b31
This commit is contained in:
Nick Steele
2026-09-30 03:33:43 +00:00
committed by copyberry
parent b588812e8c
commit c9b3924a62
9 changed files with 221 additions and 570 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+7 -9
View File
@@ -11829,13 +11829,13 @@ dependencies = [
[[package]]
name = "process-wrap"
version = "9.0.1"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd1395947e69c07400ef4d43db0051d6f773c21f647ad8b97382fc01f0204c60"
checksum = "0e3f4237d0e4741eb50bc5584db701f1299c85fa31ff0274dd6445e79dc42d12"
dependencies = [
"futures",
"indexmap 2.14.0",
"nix 0.30.1",
"nix 0.31.3",
"tokio",
"tracing",
"windows 0.62.2",
@@ -13028,9 +13028,8 @@ dependencies = [
[[package]]
name = "rmcp"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5"
version = "3.3.0"
source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3e636cab26c013eca5131103c03d20237f12c4df#3e636cab26c013eca5131103c03d20237f12c4df"
dependencies = [
"async-trait",
"base64 0.23.0",
@@ -13064,9 +13063,8 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda"
version = "3.3.0"
source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3e636cab26c013eca5131103c03d20237f12c4df#3e636cab26c013eca5131103c03d20237f12c4df"
dependencies = [
"darling 0.24.0",
"proc-macro2",
+2 -1
View File
@@ -446,7 +446,8 @@ regex-automata = { version = "0.4.13", default-features = false, features = ["st
regex-lite = "0.1.8"
regex-syntax = "0.8.8"
reqwest = { version = "0.12", features = ["cookies"] }
rmcp = { version = "=3.2.0", default-features = false }
# rmcp-v3.3.0 includes enterprise authorization helpers (rust-sdk#1234).
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "3e636cab26c013eca5131103c03d20237f12c4df", version = "=3.3.0", default-features = false }
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
rustix = { version = "1.1.4", features = ["net"] }
rustls = { version = "0.23.45", default-features = false, features = [
+1
View File
@@ -317,6 +317,7 @@ allow-git = [
"https://github.com/hyperium/h3",
"https://github.com/juberti-oai/rust-sdks.git",
"https://github.com/microsoft/mxc",
"https://github.com/modelcontextprotocol/rust-sdk",
"https://github.com/openai-oss-forks/crossterm",
"https://github.com/openai-oss-forks/tokio-tungstenite",
"https://github.com/openai-oss-forks/tungstenite-rs",
+1 -1
View File
@@ -34,7 +34,7 @@ keyring = { workspace = true, features = ["crypto-rust"] }
memchr = { workspace = true }
oauth2 = "5"
rmcp = { workspace = true, default-features = false, features = [
"auth",
"auth-enterprise-managed",
"base64",
"client",
"macros",
@@ -29,26 +29,6 @@ pub(crate) fn ema_reauthentication_required(message: &'static str) -> anyhow::Er
anyhow::Error::new(EmaAuthFailure::ReauthenticationRequired).context(message)
}
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_public_client_auth(
advertised_methods: Option<&Value>,
issuer_description: &str,
+4 -158
View File
@@ -1,7 +1,5 @@
//! Validate token routing and signed authorization before forwarding an ID-JAG.
//! Validate OIDC identity assertion bindings before storing enterprise credentials.
use std::collections::HashSet;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
@@ -13,14 +11,11 @@ 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)]
@@ -30,16 +25,7 @@ enum OAuthResource {
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)> {
fn signed_jwt<T: DeserializeOwned>(token: &str) -> Result<T> {
let mut parts = token.split('.');
let (Some(header), Some(payload), Some(signature), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
@@ -56,7 +42,7 @@ fn signed_jwt<T: DeserializeOwned>(token: &str) -> Result<(JwtHeader, T)> {
}
let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)
.map_err(|_| anyhow!("invalid identity assertion JWT claims"))?;
Ok((header, claims))
Ok(claims)
}
#[derive(Deserialize)]
@@ -73,7 +59,7 @@ pub(crate) fn oidc_identity(
expected_issuer: &str,
expected_audience: &str,
) -> Result<OidcClaims> {
let (_, claims): (_, OidcClaims) = signed_jwt(assertion)?;
let claims: OidcClaims = signed_jwt(assertion)?;
if claims.iss != expected_issuer || claims.sub.trim().is_empty() {
bail!("OIDC identity assertion issuer or subject does not match the enterprise IdP");
}
@@ -108,143 +94,3 @@ pub fn validate_oidc_identity_assertion(
}
Ok(())
}
#[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),
})
}
}
+63 -157
View File
@@ -1,31 +1,25 @@
//! Non-interactive ID-JAG exchange against explicitly trusted OAuth endpoints.
//! Adapt the SDK enterprise exchange to Codex HTTP routing and credential lifecycle errors.
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 oauth2::RefreshToken;
use rmcp::transport::auth::enterprise::EmaAuthorizationServer;
use rmcp::transport::auth::enterprise::EmaError;
use rmcp::transport::auth::enterprise::EmaExchangeRequest;
use rmcp::transport::auth::enterprise::EmaExchangeStage;
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";
#[cfg(test)]
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.
@@ -63,155 +57,67 @@ pub struct EmaIdJagExchangeRequest<'a> {
/// 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),
let idp_http = OAuthHttpClientAdapter::new_with_redirect_mode(
request.idp_http_client,
build_default_headers(/*http_headers*/ None, /*env_http_headers*/ None)?,
url,
request.idp_token_endpoint,
/*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()
)));
let resource_http = OAuthHttpClientAdapter::new_with_redirect_mode(
request.resource_http_client,
build_default_headers(/*http_headers*/ None, /*env_http_headers*/ None)?,
request.authorization_server_token_endpoint,
/*has_configured_headers*/ false,
StreamableHttpRedirectMode::Legacy,
)?;
let refresh_token = RefreshToken::new(request.refresh_token);
// Discovery has already verified both public-client registrations. The SDK owns
// token validation and the two exchanges; Codex retains routing and credential policy.
let token = EmaExchangeRequest::new(
EmaAuthorizationServer::new(
request.idp_issuer,
request.idp_token_endpoint,
request.idp_client_id,
),
EmaAuthorizationServer::new(
request.authorization_server_issuer,
request.authorization_server_token_endpoint,
request.mcp_client_id,
),
request.resource,
&refresh_token,
)
.with_scopes(request.scopes.iter().cloned())
.exchange(&idp_http, &resource_http)
.await
.map_err(|error| {
let failure = match &error {
EmaError::InvalidGrant(EmaExchangeStage::IdentityProvider) => {
Some(EmaAuthFailure::InvalidGrant {
grant_source: EmaInvalidGrantSource::EnterpriseIdentity,
})
}
EmaError::InvalidGrant(EmaExchangeStage::ResourceAuthorizationServer) => {
Some(EmaAuthFailure::InvalidGrant {
grant_source: EmaInvalidGrantSource::ResourceAuthorization,
})
}
EmaError::InsufficientUserAuthentication(_) => {
Some(EmaAuthFailure::InsufficientUserAuthentication)
}
// Future SDK stages must not invalidate the shared enterprise credential.
_ => None,
};
match failure {
Some(failure) => anyhow::Error::new(failure).context(error),
None => anyhow::Error::new(error),
}
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"))
})?;
Ok(EmaAccessToken {
access_token: token.access_token.secret().to_owned(),
expires_in: token.expires_in,
})
}
#[cfg(test)]
+140 -221
View File
@@ -19,6 +19,8 @@ use wiremock::matchers::method;
use wiremock::matchers::path;
use super::*;
use crate::ema_auth_policy::validate_ema_oauth_endpoint;
use crate::ema_claims::ID_JAG_TOKEN_TYPE;
use crate::ema_claims::validate_oidc_identity_assertion;
fn http_client() -> Arc<dyn HttpClient> {
@@ -42,7 +44,7 @@ fn unique_form_fields(body: &[u8]) -> HashMap<String, String> {
fn jwt(claims: &Value) -> String {
format!(
"{}.{}.signature",
"{}.{}.c2lnbmF0dXJl",
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"))
)
@@ -70,6 +72,25 @@ fn token_response() -> Value {
json!({"access_token":"resource-token","token_type":"Bearer","expires_in":300})
}
async fn exchange(server: &MockServer, refresh_token: &str) -> Result<EmaAccessToken> {
let issuer = format!("{}/idp", server.uri());
let audience = format!("{}/as", server.uri());
exchange_id_jag(EmaIdJagExchangeRequest {
resource: &format!("{}/mcp", server.uri()),
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: http_client(),
resource_http_client: http_client(),
})
.await
}
#[tokio::test]
async fn public_client_round_trip_preserves_signed_narrowing() -> Result<()> {
let requested_scopes = ["files.read".to_string(), "files.write".to_string()];
@@ -185,168 +206,73 @@ async fn public_client_round_trip_preserves_signed_narrowing() -> Result<()> {
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),
#[tokio::test]
async fn sdk_validation_rejects_unsupported_authorization_before_returning_a_bearer() {
for location in [
"signed claims",
"IdP response",
"resource response",
"signed scope",
] {
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:?}"
let server = MockServer::builder().start().await;
let original = claims(
&format!("{}/idp", server.uri()),
&format!("{}/as", server.uri()),
&format!("{}/mcp", server.uri()),
);
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 mut jag = jag_response(&original);
let mut bearer = token_response();
let details = json!([{"type": "payment_initiation"}]);
match location {
"signed claims" | "signed scope" => {
let mut changed = original;
if location == "signed claims" {
changed["authorization_details"] = details;
} else {
changed["scope"] = json!("files.read\tfiles.write");
}
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:?}"
);
jag = jag_response(&changed);
}
"IdP response" => jag["authorization_details"] = details,
"resource response" => bearer["authorization_details"] = details,
_ => unreachable!(),
}
}
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)),
let resource_requested = location == "resource response";
for (endpoint, response, count) in [
("/idp/token", jag, 1),
("/as/token", bearer, u64::from(resource_requested)),
] {
Mock::given(method("POST"))
.and(path(endpoint))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.expect(count)
.mount(&server)
.await;
}
);
Ok(())
let error = exchange(&server, "refresh-token")
.await
.expect_err("SDK must reject unsupported authorization");
assert_eq!(
error.downcast_ref::<EmaError>(),
Some(&EmaError::InvalidResponse {
stage: if resource_requested {
EmaExchangeStage::ResourceAuthorizationServer
} else {
EmaExchangeStage::IdentityProvider
},
message: if location == "signed scope" {
"malformed or duplicate scopes"
} else {
"authorization_details is not supported"
},
}),
"{location}"
);
assert_eq!(
server.received_requests().await.expect("requests").len(),
if resource_requested { 2 } else { 1 }
);
}
}
#[test]
@@ -384,72 +310,65 @@ fn identity_and_credential_destinations_are_bound() {
}
#[tokio::test]
async fn provider_errors_cannot_reflect_credentials() {
async fn provider_errors_preserve_credential_policy_without_reflecting_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",
),
for (endpoint, grant_source) in [
("/idp/token", EmaInvalidGrantSource::EnterpriseIdentity),
("/as/token", EmaInvalidGrantSource::ResourceAuthorization),
] {
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),
for code in [
SENTINEL,
"invalid_grant",
"insufficient_user_authentication",
"malformed",
] {
let server = MockServer::builder().start().await;
if endpoint == "/as/token" {
let jag = jag_response(&claims(
&format!("{}/idp", server.uri()),
&format!("{}/as", server.uri()),
&format!("{}/mcp", server.uri()),
));
Mock::given(method("POST"))
.and(path("/idp/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(jag))
.expect(1)
.mount(&server)
.await;
}
let response = if code == "malformed" {
ResponseTemplate::new(200).set_body_json(json!({"expires_in": SENTINEL}))
} else {
ResponseTemplate::new(400).set_body_json(json!({
"error":code,"error_description":SENTINEL,
}))
};
Mock::given(method("POST"))
.and(path(endpoint))
.respond_with(response)
.expect(1)
.mount(&server)
.await;
let error = exchange(&server, SENTINEL)
.await
.expect_err("provider error should fail");
let expected = match code {
"invalid_grant" => Some(EmaAuthFailure::InvalidGrant { grant_source }),
"insufficient_user_authentication" => {
Some(EmaAuthFailure::InsufficientUserAuthentication)
}
_ => None,
};
assert_eq!(error.downcast_ref::<EmaAuthFailure>(), expected.as_ref());
assert!(
!format!("{error:#?}").contains(SENTINEL),
"provider reflected a credential"
);
assert_eq!(
server.received_requests().await.expect("requests").len(),
if endpoint == "/as/token" { 2 } else { 1 },
"exchange must not retry a rejected grant"
);
}
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"
);
}