01d1cf9190
Wire up RFC 8693 token exchange throughout the MCP server to support stateless per-request token conversion for external IdP scenarios. Changes: Authentication Flow: - Add exchange_token_for_audience() for pure RFC 8693 exchange - Update context_helper to use stateless token exchange - Remove fallback to standard OAuth on exchange failure - Make storage initialization lazy (only for delegation, not MCP tools) Application Configuration: - Add ENABLE_TOKEN_EXCHANGE environment variable support - Skip provisioning tools when token exchange enabled - Pass mcp_client_id to token broker for proper validation - Update docker-compose.yml with token exchange config Token Exchange Service: - Add TOKEN_EXCHANGE_GRANT constant - Implement exchange_token_for_audience() method - Support both "mcp-server" and client_id audiences - Lazy storage initialization for delegation scenarios - Enhanced error handling and logging Progressive Token Verifier: - Add mcp_client_id parameter for external IdP validation - Accept both "mcp-server" and configured client_id - Support external IdP token verification Key Behavior Changes: - When ENABLE_TOKEN_EXCHANGE=true: Each MCP tool call triggers stateless token exchange (client token → Nextcloud token) - When ENABLE_TOKEN_EXCHANGE=false: Uses pass-through mode (validates Flow 1 token and passes to Nextcloud) - No provisioning tools registered in exchange mode - No refresh tokens needed for request-time operations This completes the token exchange implementation. The MCP server now supports both pass-through (default) and exchange (opt-in) modes for federated authentication architectures. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
146 lines
5.8 KiB
Python
146 lines
5.8 KiB
Python
"""Helper functions for extracting OAuth context from MCP requests."""
|
|
|
|
import logging
|
|
|
|
from mcp.server.auth.provider import AccessToken
|
|
from mcp.server.fastmcp import Context
|
|
|
|
from ..client import NextcloudClient
|
|
from ..config import get_settings
|
|
from .token_exchange import exchange_token_for_audience
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
|
"""
|
|
Extract authenticated user context from MCP request and create NextcloudClient.
|
|
|
|
This function retrieves the OAuth access token from the MCP context,
|
|
extracts the username from the token's resource field (where we stored it
|
|
during token verification), and creates a NextcloudClient with bearer auth.
|
|
|
|
Args:
|
|
ctx: MCP request context containing session info
|
|
base_url: Nextcloud base URL
|
|
|
|
Returns:
|
|
NextcloudClient configured with bearer token auth
|
|
|
|
Raises:
|
|
AttributeError: If context doesn't contain expected OAuth session data
|
|
ValueError: If username cannot be extracted from token
|
|
"""
|
|
try:
|
|
# In Starlette with FastMCP OAuth, the authenticated user info is stored in request.user
|
|
# The FastMCP auth middleware sets request.user to an AuthenticatedUser object
|
|
# which contains the access_token
|
|
if hasattr(ctx.request_context.request, "user") and hasattr(
|
|
ctx.request_context.request.user, "access_token"
|
|
):
|
|
access_token: AccessToken = ctx.request_context.request.user.access_token
|
|
logger.debug("Retrieved access token from request.user for OAuth request")
|
|
else:
|
|
logger.error(
|
|
"OAuth authentication failed: No access token found in request"
|
|
)
|
|
raise AttributeError("No access token found in OAuth request context")
|
|
|
|
# Extract username from resource field (RFC 8707)
|
|
# We stored the username here during token verification
|
|
username = access_token.resource
|
|
|
|
if not username:
|
|
logger.error("No username found in access token resource field")
|
|
raise ValueError("Username not available in OAuth token context")
|
|
|
|
logger.debug(f"Creating OAuth NextcloudClient for user: {username}")
|
|
|
|
# Create client with bearer token
|
|
return NextcloudClient.from_token(
|
|
base_url=base_url, token=access_token.token, username=username
|
|
)
|
|
|
|
except AttributeError as e:
|
|
logger.error(f"Failed to extract OAuth context: {e}")
|
|
logger.error("This may indicate the server is not running in OAuth mode")
|
|
raise
|
|
|
|
|
|
async def get_session_client_from_context(
|
|
ctx: Context, base_url: str
|
|
) -> NextcloudClient:
|
|
"""
|
|
Create NextcloudClient using RFC 8693 token exchange for session operations.
|
|
|
|
This implements the token exchange pattern where:
|
|
1. Extract Flow 1 token from context (aud: "mcp-server")
|
|
2. Exchange it for ephemeral Nextcloud token via RFC 8693
|
|
3. Create client with delegated token (NOT stored)
|
|
|
|
Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced
|
|
by the MCP server via @require_scopes decorator, not by the IdP. Therefore,
|
|
we don't pass scopes to the token exchange - the MCP server already validated
|
|
permissions before calling this function.
|
|
|
|
Args:
|
|
ctx: MCP request context containing session info
|
|
base_url: Nextcloud base URL
|
|
|
|
Returns:
|
|
NextcloudClient configured with ephemeral delegated token
|
|
|
|
Raises:
|
|
AttributeError: If context doesn't contain expected OAuth session data
|
|
RuntimeError: If token exchange fails
|
|
"""
|
|
settings = get_settings()
|
|
|
|
# Check if token exchange is enabled
|
|
if not settings.enable_token_exchange:
|
|
logger.info("Token exchange disabled, falling back to standard OAuth flow")
|
|
return get_client_from_context(ctx, base_url)
|
|
|
|
try:
|
|
# Extract Flow 1 token from context
|
|
if hasattr(ctx.request_context.request, "user") and hasattr(
|
|
ctx.request_context.request.user, "access_token"
|
|
):
|
|
access_token: AccessToken = ctx.request_context.request.user.access_token
|
|
flow1_token = access_token.token
|
|
username = access_token.resource # Username stored during verification
|
|
logger.debug(f"Retrieved Flow 1 token for user: {username}")
|
|
else:
|
|
logger.error("No Flow 1 token found in request context")
|
|
raise AttributeError("No access token found in OAuth request context")
|
|
|
|
if not username:
|
|
logger.error("No username found in access token resource field")
|
|
raise ValueError("Username not available in OAuth token context")
|
|
|
|
logger.info("Exchanging client token for Nextcloud API token (pure RFC 8693)")
|
|
|
|
# Perform pure RFC 8693 token exchange (no refresh tokens)
|
|
# Note: We don't pass scopes since Nextcloud doesn't enforce them.
|
|
# The MCP server's @require_scopes decorator handles authorization.
|
|
exchanged_token, expires_in = await exchange_token_for_audience(
|
|
subject_token=flow1_token,
|
|
requested_audience="nextcloud",
|
|
requested_scopes=None, # Nextcloud doesn't support scopes
|
|
)
|
|
|
|
logger.info(f"Pure token exchange successful. Token expires in {expires_in}s")
|
|
|
|
# Create client with exchanged token
|
|
# This token is ephemeral (per-request) and NOT stored
|
|
return NextcloudClient.from_token(
|
|
base_url=base_url, token=exchanged_token, username=username
|
|
)
|
|
|
|
except AttributeError as e:
|
|
logger.error(f"Failed to extract OAuth context: {e}")
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Token exchange failed: {e}")
|
|
raise RuntimeError(f"Token exchange required but failed: {e}") from e
|