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 mcp.server.fastmcp import Context
|
||||||
from pydantic import BaseModel, Field
|
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.refresh_token_storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
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."
|
"You'll need to complete an OAuth authorization in your browser."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@require_scopes("openid")
|
||||||
async def tool_provision_access(
|
async def tool_provision_access(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
@@ -411,6 +413,7 @@ def register_oauth_tools(mcp):
|
|||||||
name="revoke_nextcloud_access",
|
name="revoke_nextcloud_access",
|
||||||
description="Revoke offline access to Nextcloud resources.",
|
description="Revoke offline access to Nextcloud resources.",
|
||||||
)
|
)
|
||||||
|
@require_scopes("openid")
|
||||||
async def tool_revoke_access(
|
async def tool_revoke_access(
|
||||||
ctx: Context, user_id: Optional[str] = None
|
ctx: Context, user_id: Optional[str] = None
|
||||||
) -> RevocationResult:
|
) -> RevocationResult:
|
||||||
@@ -420,6 +423,7 @@ def register_oauth_tools(mcp):
|
|||||||
name="check_provisioning_status",
|
name="check_provisioning_status",
|
||||||
description="Check whether Nextcloud access is provisioned.",
|
description="Check whether Nextcloud access is provisioned.",
|
||||||
)
|
)
|
||||||
|
@require_scopes("openid")
|
||||||
async def tool_check_status(
|
async def tool_check_status(
|
||||||
ctx: Context, user_id: Optional[str] = None
|
ctx: Context, user_id: Optional[str] = None
|
||||||
) -> ProvisioningStatus:
|
) -> ProvisioningStatus:
|
||||||
|
|||||||
@@ -394,11 +394,13 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
|||||||
nc_mcp_oauth_client_no_custom_scopes,
|
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.
|
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.
|
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no resource scopes.
|
||||||
All tools require at least one custom scope, so they should all be filtered out.
|
- 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
|
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]
|
tool_names = [tool.name for tool in result.tools]
|
||||||
logger.info(
|
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
|
# Only OAuth provisioning tools should be visible (they require 'openid' scope)
|
||||||
assert len(tool_names) == 0, (
|
expected_oauth_tools = [
|
||||||
f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}"
|
"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(
|
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