feat: add scope protection to OAuth provisioning tools
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user