From 881b0ba03c269ddaae3d8b5899657ced659c69dc Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 4 Nov 2025 09:25:20 +0100 Subject: [PATCH] feat: add scope protection to OAuth provisioning tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @require_scopes("openid") decorator to OAuth backend tools (provision_nextcloud_access, revoke_nextcloud_access, check_provisioning_status) to ensure they're only visible to authenticated OIDC users. Design rationale: - OAuth provisioning tools are "meta-tools" that manage authentication itself - They don't access Nextcloud resources, so don't need resource scopes - Requiring 'openid' ensures user is authenticated via OIDC - Enables Progressive Consent: users authenticate first, then provision access - Aligns with dual OAuth flow architecture (Flow 1 + Flow 2) Changes: - Add @require_scopes("openid") to all three OAuth provisioning tools - Update test expectations: users with only OIDC default scopes see OAuth provisioning tools but not resource tools - All tests pass (13/13 in test_scope_authorization.py) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- nextcloud_mcp_server/server/oauth_tools.py | 4 +++ .../server/oauth/test_scope_authorization.py | 26 +++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/nextcloud_mcp_server/server/oauth_tools.py b/nextcloud_mcp_server/server/oauth_tools.py index 2092c4d..26a609f 100644 --- a/nextcloud_mcp_server/server/oauth_tools.py +++ b/nextcloud_mcp_server/server/oauth_tools.py @@ -14,6 +14,7 @@ from urllib.parse import urlencode from mcp.server.fastmcp import Context from pydantic import BaseModel, Field +from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage from nextcloud_mcp_server.auth.token_broker import TokenBrokerService @@ -401,6 +402,7 @@ def register_oauth_tools(mcp): "You'll need to complete an OAuth authorization in your browser." ), ) + @require_scopes("openid") async def tool_provision_access( ctx: Context, user_id: Optional[str] = None, @@ -411,6 +413,7 @@ def register_oauth_tools(mcp): name="revoke_nextcloud_access", description="Revoke offline access to Nextcloud resources.", ) + @require_scopes("openid") async def tool_revoke_access( ctx: Context, user_id: Optional[str] = None ) -> RevocationResult: @@ -420,6 +423,7 @@ def register_oauth_tools(mcp): name="check_provisioning_status", description="Check whether Nextcloud access is provisioned.", ) + @require_scopes("openid") async def tool_check_status( ctx: Context, user_id: Optional[str] = None ) -> ProvisioningStatus: diff --git a/tests/server/oauth/test_scope_authorization.py b/tests/server/oauth/test_scope_authorization.py index 0f30257..fa7d0ca 100644 --- a/tests/server/oauth/test_scope_authorization.py +++ b/tests/server/oauth/test_scope_authorization.py @@ -394,11 +394,13 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools( nc_mcp_oauth_client_no_custom_scopes, ): """ - Test that a JWT token with only OIDC default scopes (no nc:read or nc:write) returns 0 tools. + Test that a JWT token with only OIDC default scopes shows only OAuth provisioning tools. This tests the security behavior when a user declines to grant custom scopes during consent. - Expected: JWT token has scopes=['openid', 'profile', 'email'] but no nc:read or nc:write. - All tools require at least one custom scope, so they should all be filtered out. + Expected: JWT token has scopes=['openid', 'profile', 'email'] but no resource scopes. + - Resource tools (notes:*, calendar:*, etc.) are filtered out + - OAuth provisioning tools (requiring only 'openid') remain visible + so users can provision Nextcloud access after authentication """ import logging @@ -410,16 +412,24 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools( tool_names = [tool.name for tool in result.tools] logger.info( - f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)" + f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 3 OAuth tools)" ) - # All tools require nc:read or nc:write, so should be filtered out - assert len(tool_names) == 0, ( - f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}" + # Only OAuth provisioning tools should be visible (they require 'openid' scope) + expected_oauth_tools = [ + "provision_nextcloud_access", + "revoke_nextcloud_access", + "check_provisioning_status", + ] + + assert set(tool_names) == set(expected_oauth_tools), ( + f"Expected only OAuth provisioning tools {expected_oauth_tools} " + f"but got {tool_names}" ) logger.info( - "✅ JWT token without custom scopes correctly returns 0 tools (all filtered out)" + f"✅ JWT token with only openid scope correctly shows {len(tool_names)} OAuth provisioning tools, " + "resource tools filtered out" )