Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions crates/rmcp/src/transport/auth/enterprise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Self>, D::Error> {
Self::deserialize(deserializer).map(Some)
}
}

#[derive(Deserialize)]
Expand All @@ -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")]
Comment thread
daviddanialy marked this conversation as resolved.
resource: Option<Resource>,
scope: Option<String>,
authorization_details: Option<Vec<serde_json::Value>>,
}
Expand Down Expand Up @@ -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"));
}
Expand Down
69 changes: 69 additions & 0 deletions crates/rmcp/src/transport/auth/enterprise_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions docs/OAUTH_SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading