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 &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() {