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 <[email protected]>
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 &mdash; 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} &mdash; 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) &mdash; 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" &mdash; 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() {

Reply via email to