You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/08/16 17:40:51 UTC
(juneau) 02/04: READY-380: Require exact OAuth scope match in MCP resource server
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
commit eed551345938ffd98940e559b5f8735588ea63e3
Author: James Bognar <ja...@apache.org>
AuthorDate: Sun Aug 16 13:40:23 2026 -0400
READY-380: Require exact OAuth scope match in MCP resource server
McpResourceServerSupport.satisfies() treated a granted scope as implying any
required scope under a "scope:"/"scope." prefix, letting a broad low-privilege
grant authorize a more-privileged operation. Switch to exact-match set semantics.
---
.../mcp/v20260728/McpResourceServerSupport.java | 25 ++++----------
.../v20260728/McpResourceServerBinding_Test.java | 28 +++++++++-------
.../v20260728/McpResourceServerSupport_Test.java | 38 ++++++++++++++--------
3 files changed, 49 insertions(+), 42 deletions(-)
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport.java
index c1b946d06f..b83e1df9cb 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport.java
@@ -513,32 +513,21 @@ public final class McpResourceServerSupport {
}
/**
- * Returns whether the granted scopes satisfy every required scope, honoring OAuth scope hierarchies where a broader
- * granted scope implies its narrower children.
+ * Returns whether the granted scopes satisfy every required scope, using exact string equality.
*
* <p>
- * A required scope {@code r} is satisfied when some granted scope {@code g} either equals {@code r} exactly, or is a
- * hierarchical <em>ancestor</em> of it — i.e. {@code r} begins with {@code g} followed by a {@code :} or
- * {@code .} delimiter (so granted {@code repo} implies required {@code repo:read} / {@code repo.write}, but granted
- * {@code repo:read} does <b>not</b> imply required {@code repo}).
+ * A required scope {@code r} is satisfied only when some granted scope {@code g} is exactly equal to it. OAuth
+ * scopes have no universal hierarchy, so a granted {@code repo} does <b>not</b> imply a required {@code repo:write}
+ * or {@code repo.admin} — a broad-but-low-privilege scope must never authorize a differently named privileged
+ * operation. Applications that want scope implication must expand their granted scopes themselves before calling
+ * this method; this method does not provide a hierarchical-implication SPI.
*
* @param grantedScopes The caller's granted scopes. Never <jk>null</jk>.
* @param requiredScopes The scopes required to invoke the operation. Never <jk>null</jk>.
* @return <jk>true</jk> if every required scope is satisfied.
*/
public static boolean satisfies(Collection<String> grantedScopes, Collection<String> requiredScopes) {
- for (var r : requiredScopes) {
- var ok = false;
- for (var g : grantedScopes) {
- if (g.equals(r) || r.startsWith(g + ":") || r.startsWith(g + ".")) {
- ok = true;
- break;
- }
- }
- if (!ok)
- return false;
- }
- return true;
+ return grantedScopes.containsAll(requiredScopes);
}
/**
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java
index 660d5a352f..415cd660a6 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java
@@ -72,11 +72,12 @@ class McpResourceServerBinding_Test extends TestBase {
case "good4" -> new ClaimsPrincipal("alice", Map.of("aud", RESOURCE, "scope", "mcp.read mcp.write", "iss", "https://idp-b", "sub", "alice"));
case "noscope" -> new ClaimsPrincipal("alice", Map.of("aud", RESOURCE, "scope", "other"));
case "wrongaud" -> new ClaimsPrincipal("alice", Map.of("aud", "http://evil.example.com", "scope", "mcp.read"));
- // SEP-2350 step-up fixtures: baseline mcp.read + the per-operation tools.exec (exact and hierarchical grants).
+ // SEP-2350 step-up fixtures: baseline mcp.read + the per-operation tools.exec (exact grant vs. a
+ // broader-but-not-exact grant that must NOT satisfy it).
case "opok" -> new ClaimsPrincipal("alice", Map.of("aud", RESOURCE, "scope", "mcp.read tools.exec"));
case "broad" -> new ClaimsPrincipal("alice", Map.of("aud", RESOURCE, "scope", "mcp.read tools"));
- // H1 baseline-hierarchy fixture: granted ONLY the broad ancestor "mcp", which satisfies the baseline
- // "mcp.read" hierarchically (never exact-string).
+ // Baseline fixture: granted ONLY the broader "mcp" scope, which does NOT satisfy the exact-match baseline
+ // requirement "mcp.read" (OAuth scopes have no universal hierarchy).
case "basehier" -> new ClaimsPrincipal("alice", Map.of("aud", RESOURCE, "scope", "mcp"));
default -> throw new AuthenticationException("bad token");
};
@@ -201,13 +202,15 @@ class McpResourceServerBinding_Test extends TestBase {
assertContains("alice", s);
}
- // H1: a token granted ONLY the broad ancestor "mcp" satisfies the baseline "mcp.read" hierarchically and
- // dispatches. The old exact-string containsAll baseline gate would have 403'd this.
- @Test void a10_baselineHierarchicalScope_dispatches() throws Exception {
+ // A token granted only the broader "mcp" scope does NOT satisfy the exact-match baseline requirement
+ // "mcp.read": a broad-but-differently-named scope must never authorize a differently named privileged operation.
+ @Test void a10_baselineBroaderScopeDoesNotSatisfy_403() throws Exception {
clientA().post("/").contentString(body(1, "server/discover", null))
.header("Mcp-Method", "server/discover").header("Mcp-Name", "")
.header("Authorization", "Bearer basehier")
- .run().assertStatus(200);
+ .run()
+ .assertStatus(403)
+ .assertHeader("WWW-Authenticate").is("Bearer realm=\"mcp\", error=\"insufficient_scope\", scope=\"mcp.read\", resource_metadata=\"" + PRM_URL + "\"");
}
// M5: the wildcard well-known route must not serve this endpoint's PRM for an arbitrary suffix.
@@ -218,7 +221,7 @@ class McpResourceServerBinding_Test extends TestBase {
// ---------------------------------------------------------------------------------------------
// SEP-2350 (F3) per-operation step-up: an end-to-end HTTP round-trip exercising the POST-parse enforcement
// point in McpRevision.dispatch (a token satisfying the endpoint-wide baseline but not the operation's scope
- // yields a scoped 403 insufficient_scope challenge; the correct scope, exact or hierarchical, dispatches).
+ // yields a scoped 403 insufficient_scope challenge; only the exact required scope dispatches).
// ---------------------------------------------------------------------------------------------
private static McpOptions rsEnabledWithOpScope() {
@@ -266,12 +269,15 @@ class McpResourceServerBinding_Test extends TestBase {
.run().assertStatus(200);
}
- // A broader ancestor scope (tools) satisfies the narrower required tools.exec (server MUST: hierarchy-aware).
- @Test void d03_stepUp_hierarchicalScope_dispatches() throws Exception {
+ // A broader scope (tools) does NOT satisfy the exact required scope tools.exec: OAuth scopes have no universal
+ // hierarchy, so this must be a scoped 403 step-up challenge, not a dispatch.
+ @Test void d03_stepUp_broaderScopeDoesNotSatisfy_403() throws Exception {
clientD().post("/").contentString(body(1, "tools/call", callEcho()))
.header("Mcp-Method", "tools/call").header("Mcp-Name", "echo")
.header("Authorization", "Bearer broad")
- .run().assertStatus(200);
+ .run()
+ .assertStatus(403)
+ .assertHeader("WWW-Authenticate").is("Bearer realm=\"mcp\", error=\"insufficient_scope\", scope=\"tools.exec\", resource_metadata=\"" + PRM_URL + "\"");
}
// An operation with no per-operation scope configured is unaffected: baseline mcp.read alone dispatches it.
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport_Test.java
index 90338624bb..20f9446b53 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport_Test.java
@@ -191,8 +191,9 @@ class McpResourceServerSupport_Test {
}
// ---------------------------------------------------------------------------------------------
- // SEP-2350 per-operation step-up: scope satisfaction (hierarchy-aware), scoped challenge shape, and the
- // POST-parse enforcement decision (throws 403 insufficient_scope + WWW-Authenticate, or is a no-op).
+ // SEP-2350 per-operation step-up: scope satisfaction (exact-match only, no hierarchy/prefix inheritance),
+ // scoped challenge shape, and the POST-parse enforcement decision (throws 403 insufficient_scope +
+ // WWW-Authenticate, or is a no-op).
// ---------------------------------------------------------------------------------------------
private static McpOperationContext op(String method, String name) {
@@ -203,10 +204,12 @@ class McpResourceServerSupport_Test {
assertTrue(McpResourceServerSupport.satisfies(Set.of("repo.delete"), Set.of("repo.delete")));
}
- @Test void f02_satisfies_broaderAncestorImpliesNarrower() {
- // Granted "repo" implies required "repo.delete" (dot) and "repo:delete" (colon).
- assertTrue(McpResourceServerSupport.satisfies(Set.of("repo"), Set.of("repo.delete")));
- assertTrue(McpResourceServerSupport.satisfies(Set.of("repo"), Set.of("repo:delete")));
+ @Test void f02_satisfies_broaderScopeDoesNotImplyNarrower() {
+ // OAuth scopes have no universal hierarchy: granted "repo" must NOT satisfy required "repo.delete" (dot) or
+ // "repo:delete" (colon) — a broad-but-low-privilege scope must never authorize a differently named,
+ // more-privileged operation.
+ assertFalse(McpResourceServerSupport.satisfies(Set.of("repo"), Set.of("repo.delete")));
+ assertFalse(McpResourceServerSupport.satisfies(Set.of("repo"), Set.of("repo:delete")));
}
@Test void f03_satisfies_narrowerDoesNotImplyBroader() {
@@ -214,8 +217,8 @@ class McpResourceServerSupport_Test {
assertFalse(McpResourceServerSupport.satisfies(Set.of("repo.read"), Set.of("repo")));
}
- @Test void f04_satisfies_partialTokenIsNotAncestor() {
- // "rep" is a string prefix of "repo.read" but NOT a hierarchical ancestor (no delimiter), so it must not satisfy.
+ @Test void f04_satisfies_partialTokenIsNotAMatch() {
+ // "rep" is a string prefix of "repo.read" but not an exact match, so it must not satisfy.
assertFalse(McpResourceServerSupport.satisfies(Set.of("rep"), Set.of("repo.read")));
}
@@ -224,11 +227,20 @@ class McpResourceServerSupport_Test {
assertTrue(McpResourceServerSupport.satisfies(Set.of("a", "b"), Set.of("a", "b")));
}
- @Test void f05b_satisfies_baselineParity_grantedAncestorSatisfiesBaseline() {
- // H1 parity: the baseline required-scope gate uses satisfies(...) exactly like the per-operation gate, so a
- // token granted "mcp" satisfies a baseline of "mcp.read" hierarchically (no exact-string containsAll needed).
- assertTrue(McpResourceServerSupport.satisfies(Set.of("mcp"), Set.of("mcp.read")));
- assertFalse(Set.of("mcp").containsAll(Set.of("mcp.read"))); // the OLD containsAll gate would have 403'd this.
+ @Test void f05b_satisfies_baselineParity_grantedBroaderScopeDoesNotSatisfyBaseline() {
+ // Parity: the baseline required-scope gate uses satisfies(...) exactly like the per-operation gate, so a
+ // token granted only "mcp" does NOT satisfy a baseline of "mcp.read" — exact-match only, no
+ // prefix/hierarchy inheritance.
+ assertFalse(McpResourceServerSupport.satisfies(Set.of("mcp"), Set.of("mcp.read")));
+ }
+
+ @Test void f05c_satisfies_emptyRequired_alwaysTrue() {
+ assertTrue(McpResourceServerSupport.satisfies(Set.of(), Set.of()));
+ assertTrue(McpResourceServerSupport.satisfies(Set.of("a"), Set.of()));
+ }
+
+ @Test void f05d_satisfies_emptyGrantedNonEmptyRequired_false() {
+ assertFalse(McpResourceServerSupport.satisfies(Set.of(), Set.of("a")));
}
@Test void f06_stepUpChallengeShape() {