From 51e8177fc563b489b5843e7c39b776a942f0d286 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Wed, 28 Oct 2026 09:31:54 -0700 Subject: [PATCH 1/2] fix(auth): accept ID-JAGs without a resource claim --- crates/rmcp/src/transport/auth/enterprise.rs | 26 +++++-- .../src/transport/auth/enterprise_tests.rs | 69 +++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/rmcp/src/transport/auth/enterprise.rs b/crates/rmcp/src/transport/auth/enterprise.rs index dcc4c464b..a212e6adb 100644 --- a/crates/rmcp/src/transport/auth/enterprise.rs +++ b/crates/rmcp/src/transport/auth/enterprise.rs @@ -593,6 +593,14 @@ impl Resource { Self::Multiple(values) => values.as_slice() == [expected], } } + + /// Distinguishes an omitted claim (`None` via `serde(default)`) from an explicit `null`, + /// which is rejected. + fn deserialize_present<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + Self::deserialize(deserializer).map(Some) + } } #[derive(Deserialize)] @@ -610,7 +618,10 @@ struct IdJagClaims { jti: String, exp: u64, iat: u64, - resource: Resource, + /// OPTIONAL per draft-ietf-oauth-identity-assertion-authz-grant; when present, the + /// MCP EMA profile requires it to be the MCP server's resource identifier. + #[serde(default, deserialize_with = "Resource::deserialize_present")] + resource: Option, scope: Option, authorization_details: Option>, } @@ -684,11 +695,14 @@ impl IdJagResponse { if claims.exp <= now || claims.iat > now.saturating_add(60) { return Err(invalid("expired or future-issued ID-JAG")); } - if !claims.resource.is_exact(request.resource) - || self - .resource - .as_ref() - .is_some_and(|r| !r.is_exact(request.resource)) + // Without a resource claim, the ID-JAG is bound to the resource AS by `aud` alone. + if [&claims.resource, &self.resource] + .into_iter() + .any(|resource| { + resource + .as_ref() + .is_some_and(|r| !r.is_exact(request.resource)) + }) { return Err(invalid("ID-JAG resource mismatch")); } diff --git a/crates/rmcp/src/transport/auth/enterprise_tests.rs b/crates/rmcp/src/transport/auth/enterprise_tests.rs index e29e32108..b955470a0 100644 --- a/crates/rmcp/src/transport/auth/enterprise_tests.rs +++ b/crates/rmcp/src/transport/auth/enterprise_tests.rs @@ -336,6 +336,75 @@ async fn invalid_jags_never_escape_validation() { } } +#[tokio::test] +async fn resource_claim_may_be_omitted_but_must_match_when_present() { + for (signed, echoed, valid) in [ + (None, None, true), + (None, Some(json!(RESOURCE)), true), + (None, Some(json!("https://other.example")), false), + (Some(json!(RESOURCE)), None, true), + (Some(json!([RESOURCE])), None, true), + (Some(json!("https://other.example")), None, false), + (Some(json!([RESOURCE, "other"])), None, false), + (Some(json!("")), None, false), + (Some(json!([])), None, false), + (Some(Value::Null), None, false), + ] { + let mut claims = claims(); + claims.as_object_mut().unwrap().remove("resource"); + if let Some(resource) = &signed { + claims["resource"] = resource.clone(); + } + let mut response = jag(&claims); + response.as_object_mut().unwrap().remove("resource"); + if let Some(resource) = &echoed { + response["resource"] = resource.clone(); + } + let result = exchange(&MockHttp::new(200, response), "files.read").await; + assert_eq!( + result.is_ok(), + valid, + "signed={signed:?}, echoed={echoed:?}" + ); + if !valid { + assert!(matches!( + result, + Err(EmaError::InvalidResponse { stage: Idp, .. }) + )); + } + } +} + +#[tokio::test] +async fn an_assertion_without_a_resource_claim_is_redeemed_for_the_requested_resource() { + let mut claims = claims(); + claims.as_object_mut().unwrap().remove("resource"); + let response = jag(&claims); + let idp = MockHttp::new(200, response.clone()); + let resource_as = MockHttp::new(200, bearer()); + let refresh = RefreshToken::new("refresh-token".into()); + let token = EmaExchangeRequest::new( + EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp"), + EmaAuthorizationServer::new(AS, AS_TOKEN, "mcp"), + RESOURCE, + &refresh, + ) + .with_scopes(["files.read"]) + .exchange(&idp, &resource_as) + .await + .unwrap(); + assert_eq!(token.access_token.secret(), "resource-token"); + let requests = resource_as.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!( + serde_json::to_value(form(&requests[0])).unwrap(), + json!({ + "grant_type":"urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion":response["access_token"],"client_id":"mcp" + }) + ); +} + #[tokio::test] async fn invalid_inputs_fail_before_http() { // Each server occupies issuer, token endpoint, and client ID slots. From 446c5603483cc9f64695078c4c8e771e87642bc9 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Wed, 28 Oct 2026 14:49:58 -0700 Subject: [PATCH 2/2] docs(auth): note the ID-JAG resource claim is optional --- docs/OAUTH_SUPPORT.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index ffd3bf22b..bf9c48e6c 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -307,8 +307,9 @@ The exchange profile has these requirements and limits: `EmaClientAuthentication`: `None`, `ClientSecretBasic`, `ClientSecretPost`, or `JwtAssertion`. The SDK does not select methods from metadata or fall back to a different method after a failure. -- Input is an enterprise IdP refresh token. The requested MCP resource must match - the ID-JAG's sole `resource` claim; scope may be omitted or narrowed. +- Input is an enterprise IdP refresh token. The ID-JAG's `resource` claim is + optional; when present, it must be exactly the requested MCP resource. Scope may + be omitted or narrowed. - RAR (`authorization_details`) and DPoP are not supported. Nonempty authorization details are rejected at both exchange stages. - Redemption consumes the SDK's ID-JAG handle and does not retry automatically.