From 774ab2c56137578b57a13d0adc7b6093a6b1222b Mon Sep 17 00:00:00 2001 From: strawgate Date: Sat, 16 May 2026 23:49:23 -0500 Subject: [PATCH] fix(auth): disambiguate auth-denied vs missing component messages (refs #4054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../server/middleware/authorization.py | 24 ++++-- tests/server/auth/test_authorization.py | 74 +++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/middleware/authorization.py b/fastmcp_slim/fastmcp/server/middleware/authorization.py index 19b0503709..d520ae685d 100644 --- a/fastmcp_slim/fastmcp/server/middleware/authorization.py +++ b/fastmcp_slim/fastmcp/server/middleware/authorization.py @@ -136,11 +136,15 @@ async def on_call_tool( f"Authorization failed for tool '{tool_name}': missing context" ) - # Get tool (component auth is checked in get_tool, raises if unauthorized) + # get_tool returns None both when the tool does not exist and when + # component-level auth denied access, so the two cases are + # indistinguishable here. Keep the message ambiguous to avoid + # disclosing existence of tools the caller is not authorized to see. tool = await fastmcp.fastmcp.get_tool(tool_name) if tool is None: raise AuthorizationError( - f"Authorization failed for tool '{tool_name}': tool not found" + f"Authorization failed for tool '{tool_name}': " + "not found or not authorized" ) # Global auth check @@ -204,13 +208,17 @@ async def on_read_resource( f"Authorization failed for resource '{uri}': missing context" ) - # Get resource/template (component auth is checked in get_*, raises if unauthorized) + # get_resource/get_resource_template return None both when the resource + # does not exist and when component-level auth denied access, so the two + # cases are indistinguishable here. Keep the message ambiguous to avoid + # disclosing existence of resources the caller is not authorized to see. component = await fastmcp.fastmcp.get_resource(str(uri)) if component is None: component = await fastmcp.fastmcp.get_resource_template(str(uri)) if component is None: raise AuthorizationError( - f"Authorization failed for resource '{uri}': resource not found" + f"Authorization failed for resource '{uri}': " + "not found or not authorized" ) # Global auth check @@ -303,11 +311,15 @@ async def on_get_prompt( f"Authorization failed for prompt '{prompt_name}': missing context" ) - # Get prompt (component auth is checked in get_prompt, raises if unauthorized) + # get_prompt returns None both when the prompt does not exist and when + # component-level auth denied access, so the two cases are + # indistinguishable here. Keep the message ambiguous to avoid + # disclosing existence of prompts the caller is not authorized to see. prompt = await fastmcp.fastmcp.get_prompt(prompt_name) if prompt is None: raise AuthorizationError( - f"Authorization failed for prompt '{prompt_name}': prompt not found" + f"Authorization failed for prompt '{prompt_name}': " + "not found or not authorized" ) # Global auth check diff --git a/tests/server/auth/test_authorization.py b/tests/server/auth/test_authorization.py index 64e78599e5..8663ba6296 100644 --- a/tests/server/auth/test_authorization.py +++ b/tests/server/auth/test_authorization.py @@ -810,3 +810,77 @@ def admin_tool() -> str: ) finally: auth_context_var.reset(tok) + + +# ============================================================================= +# Tests for component-level auth denial messaging (issue #4054 bug 1) +# ============================================================================= + + +def _allow_all(ctx: AuthContext) -> bool: + """Global auth check that always passes (component-level auth still applies).""" + return True + + +class TestComponentAuthDenialMessage: + """When component-level auth denies access, get_tool/get_resource/get_prompt + return None, so the middleware cannot distinguish "missing" from "denied". + + The message must stay ambiguous ("not found or not authorized") rather than + asserting the component does not exist (misleading) or that it exists but is + forbidden (leaks existence to unauthorized callers). + """ + + async def test_call_tool_denied_by_component_auth(self): + mcp = FastMCP(middleware=[AuthMiddleware(auth=_allow_all)]) + + @mcp.tool(auth=require_scopes("admin")) + def secret_tool() -> str: + return "secret" + + token = make_token(scopes=["read"]) + tok = set_token(token) + try: + async with Client(mcp) as client: + with pytest.raises(Exception) as exc_info: + await client.call_tool("secret_tool", {}) + message = str(exc_info.value) + assert "not found or not authorized" in message + finally: + auth_context_var.reset(tok) + + async def test_read_resource_denied_by_component_auth(self): + mcp = FastMCP(middleware=[AuthMiddleware(auth=_allow_all)]) + + @mcp.resource("data://secret", auth=require_scopes("admin")) + def secret_resource() -> str: + return "secret" + + token = make_token(scopes=["read"]) + tok = set_token(token) + try: + async with Client(mcp) as client: + with pytest.raises(Exception) as exc_info: + await client.read_resource("data://secret") + message = str(exc_info.value) + assert "not found or not authorized" in message + finally: + auth_context_var.reset(tok) + + async def test_get_prompt_denied_by_component_auth(self): + mcp = FastMCP(middleware=[AuthMiddleware(auth=_allow_all)]) + + @mcp.prompt(auth=require_scopes("admin")) + def secret_prompt() -> str: + return "secret" + + token = make_token(scopes=["read"]) + tok = set_token(token) + try: + async with Client(mcp) as client: + with pytest.raises(Exception) as exc_info: + await client.get_prompt("secret_prompt") + message = str(exc_info.value) + assert "not found or not authorized" in message + finally: + auth_context_var.reset(tok)