feat: integrate token exchange into MCP server application

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>
This commit is contained in:
Chris Coutinho
2025-11-04 02:32:40 +01:00
co-authored by Claude
parent 0ff85dbe4f
commit 01d1cf9190
7 changed files with 201 additions and 36 deletions
+11 -15
View File
@@ -7,7 +7,7 @@ from mcp.server.fastmcp import Context
from ..client import NextcloudClient
from ..config import get_settings
from .token_exchange import exchange_token_for_delegation
from .token_exchange import exchange_token_for_audience
logger = logging.getLogger(__name__)
@@ -118,25 +118,23 @@ async def get_session_client_from_context(
logger.error("No username found in access token resource field")
raise ValueError("Username not available in OAuth token context")
logger.info("Exchanging Flow 1 token for ephemeral Nextcloud token")
logger.info("Exchanging client token for Nextcloud API token (pure RFC 8693)")
# Perform RFC 8693 token exchange
# 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.
delegated_token, expires_in = await exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=None, # Nextcloud doesn't support scopes
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"Token exchange successful. Ephemeral token expires in {expires_in}s"
)
logger.info(f"Pure token exchange successful. Token expires in {expires_in}s")
# Create client with ephemeral delegated token
# This token is NOT stored and will be discarded after use
# Create client with exchanged token
# This token is ephemeral (per-request) and NOT stored
return NextcloudClient.from_token(
base_url=base_url, token=delegated_token, username=username
base_url=base_url, token=exchanged_token, username=username
)
except AttributeError as e:
@@ -144,6 +142,4 @@ async def get_session_client_from_context(
raise
except Exception as e:
logger.error(f"Token exchange failed: {e}")
# Fall back to standard OAuth flow if token exchange fails
logger.info("Falling back to standard OAuth flow")
return get_client_from_context(ctx, base_url)
raise RuntimeError(f"Token exchange required but failed: {e}") from e