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:
Chris Coutinho
2025-11-03 20:33:55 +01:00
committed by Chris Coutinho
co-authored by Claude
parent 3b4606b798
commit d768909fd4
7 changed files with 804 additions and 60 deletions
+3
View File
@@ -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)
+44 -18
View File
@@ -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)