feat: Implement ADR-004 Progressive Consent foundation (partial)
Implements Progressive Consent architecture with dual OAuth flows: - Flow 1: Direct client authentication (aud: "mcp-server") - Flow 2: Resource provisioning with refresh tokens Components added: - Client registry with validation (client_registry.py) - Progressive token verifier (progressive_token_verifier.py) - Token broker service integration - Provisioning decorator for MCP tools - OAuth provisioning tools (provision_nextcloud_access, etc.) Configuration: - Progressive Consent enabled by default (ENABLE_PROGRESSIVE_CONSENT=true) - Client validation with pre-registered clients - Audience separation framework KNOWN ISSUE - Token Exchange Pattern Incorrect: The current implementation does NOT properly implement token exchange. MCP session tokens should be EXCHANGED for delegated Nextcloud tokens during tool calls, not stored/reused. Critical corrections needed: 1. Session tokens: Flow 1 token → exchange → ephemeral Nextcloud token - Generated on-demand per tool call - Short-lived, not stored - Scopes limited to tool requirements 2. Background tokens: Flow 2 refresh token → background Nextcloud token - Only for offline/background jobs - Potentially different scopes than session tokens - Must NOT be used for MCP session tool calls The token exchange mechanism needs to be implemented to properly separate session-time delegation from background job authorization. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.auth.provisioning_decorator import require_provisioning
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.notes import (
|
||||
AppendContentResponse,
|
||||
@@ -86,6 +87,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
@require_provisioning
|
||||
async def nc_notes_create_note(
|
||||
title: str, content: str, category: str, ctx: Context
|
||||
) -> CreateNoteResponse:
|
||||
@@ -247,6 +249,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
@require_provisioning
|
||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
|
||||
client = get_client(ctx)
|
||||
|
||||
@@ -56,7 +56,7 @@ class RevocationResult(BaseModel):
|
||||
message: str = Field(description="Status message for the user")
|
||||
|
||||
|
||||
async def get_provisioning_status(mcp: Context, user_id: str) -> ProvisioningStatus:
|
||||
async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningStatus:
|
||||
"""
|
||||
Check the provisioning status for Nextcloud access.
|
||||
|
||||
@@ -140,7 +140,7 @@ def generate_oauth_url_for_flow2(
|
||||
|
||||
|
||||
async def provision_nextcloud_access(
|
||||
mcp: Context, user_id: Optional[str] = None
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> ProvisioningResult:
|
||||
"""
|
||||
MCP Tool: Provision offline access to Nextcloud resources.
|
||||
@@ -151,20 +151,33 @@ async def provision_nextcloud_access(
|
||||
The user must complete the OAuth flow in their browser to grant access.
|
||||
|
||||
Args:
|
||||
mcp: MCP context
|
||||
ctx: MCP context with user's Flow 1 token
|
||||
user_id: Optional user identifier (extracted from token if not provided)
|
||||
|
||||
Returns:
|
||||
ProvisioningResult with authorization URL or status
|
||||
"""
|
||||
try:
|
||||
# Get user ID from context if not provided
|
||||
# Extract user ID from the MCP access token (Flow 1 token)
|
||||
if not user_id:
|
||||
# In a real implementation, extract from the MCP access token
|
||||
user_id = mcp.context.get("user_id", "default_user")
|
||||
# Get the authorization token from context
|
||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||
token = ctx.authorization.token
|
||||
# Decode token to get user info
|
||||
try:
|
||||
import jwt
|
||||
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub", "unknown")
|
||||
logger.info(f"Extracted user_id from Flow 1 token: {user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode token: {e}")
|
||||
user_id = "default_user"
|
||||
else:
|
||||
user_id = "default_user"
|
||||
|
||||
# Check if already provisioned
|
||||
status = await get_provisioning_status(mcp, user_id)
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
if status.is_provisioned:
|
||||
return ProvisioningResult(
|
||||
success=True,
|
||||
@@ -271,7 +284,7 @@ async def provision_nextcloud_access(
|
||||
|
||||
|
||||
async def revoke_nextcloud_access(
|
||||
mcp: Context, user_id: Optional[str] = None
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> RevocationResult:
|
||||
"""
|
||||
MCP Tool: Revoke offline access to Nextcloud resources.
|
||||
@@ -289,10 +302,14 @@ async def revoke_nextcloud_access(
|
||||
try:
|
||||
# Get user ID from context if not provided
|
||||
if not user_id:
|
||||
user_id = mcp.context.get("user_id", "default_user")
|
||||
user_id = (
|
||||
ctx.context.get("user_id", "default_user")
|
||||
if hasattr(ctx, "context")
|
||||
else "default_user"
|
||||
)
|
||||
|
||||
# Check current status
|
||||
status = await get_provisioning_status(mcp, user_id)
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
if not status.is_provisioned:
|
||||
return RevocationResult(
|
||||
success=True,
|
||||
@@ -346,7 +363,7 @@ async def revoke_nextcloud_access(
|
||||
|
||||
|
||||
async def check_provisioning_status(
|
||||
mcp: Context, user_id: Optional[str] = None
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> ProvisioningStatus:
|
||||
"""
|
||||
MCP Tool: Check the current provisioning status.
|
||||
@@ -363,9 +380,13 @@ async def check_provisioning_status(
|
||||
"""
|
||||
# Get user ID from context if not provided
|
||||
if not user_id:
|
||||
user_id = mcp.context.get("user_id", "default_user")
|
||||
user_id = (
|
||||
ctx.context.get("user_id", "default_user")
|
||||
if hasattr(ctx, "context")
|
||||
else "default_user"
|
||||
)
|
||||
|
||||
return await get_provisioning_status(mcp, user_id)
|
||||
return await get_provisioning_status(ctx, user_id)
|
||||
|
||||
|
||||
# Register MCP tools
|
||||
@@ -381,20 +402,25 @@ def register_oauth_tools(mcp):
|
||||
),
|
||||
)
|
||||
async def tool_provision_access(
|
||||
ctx: Context,
|
||||
user_id: Optional[str] = None,
|
||||
) -> ProvisioningResult:
|
||||
return await provision_nextcloud_access(mcp, user_id)
|
||||
return await provision_nextcloud_access(ctx, user_id)
|
||||
|
||||
@mcp.tool(
|
||||
name="revoke_nextcloud_access",
|
||||
description="Revoke offline access to Nextcloud resources.",
|
||||
)
|
||||
async def tool_revoke_access(user_id: Optional[str] = None) -> RevocationResult:
|
||||
return await revoke_nextcloud_access(mcp, user_id)
|
||||
async def tool_revoke_access(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> RevocationResult:
|
||||
return await revoke_nextcloud_access(ctx, user_id)
|
||||
|
||||
@mcp.tool(
|
||||
name="check_provisioning_status",
|
||||
description="Check whether Nextcloud access is provisioned.",
|
||||
)
|
||||
async def tool_check_status(user_id: Optional[str] = None) -> ProvisioningStatus:
|
||||
return await check_provisioning_status(mcp, user_id)
|
||||
async def tool_check_status(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> ProvisioningStatus:
|
||||
return await check_provisioning_status(ctx, user_id)
|
||||
|
||||
Reference in New Issue
Block a user