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
parent 0ff85dbe4f
commit 01d1cf9190
7 changed files with 201 additions and 36 deletions
+3
View File
@@ -165,6 +165,9 @@ services:
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
# Token exchange (RFC 8693) - convert aud:nextcloud-mcp-server → aud:nextcloud
- ENABLE_TOKEN_EXCHANGE=true
# OAuth scopes (optional - uses defaults if not specified)
- NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
+9 -2
View File
@@ -580,6 +580,7 @@ async def setup_oauth_config():
oidc_discovery_url=discovery_url,
nextcloud_host=nextcloud_host,
encryption_key=encryption_key,
mcp_client_id=client_id,
)
logger.info(
@@ -753,10 +754,16 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
)
# Register OAuth provisioning tools (Progressive Consent always enabled in OAuth mode)
if oauth_enabled:
# Register OAuth provisioning tools (only when offline access/Progressive Consent is used)
# With token exchange enabled (external IdP), provisioning is not needed for MCP operations
enable_token_exchange = (
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
)
if oauth_enabled and not enable_token_exchange:
logger.info("Registering OAuth provisioning tools for Progressive Consent")
register_oauth_tools(mcp)
elif oauth_enabled and enable_token_exchange:
logger.info("Skipping provisioning tools registration (token exchange enabled)")
# Override list_tools to filter based on user's token scopes (OAuth mode only)
if oauth_enabled:
+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
@@ -2,7 +2,7 @@
Token Verifier for ADR-004 Progressive Consent Architecture.
This module implements token verification with strict audience separation:
- Flow 1 tokens have aud: "mcp-server" for MCP authentication
- Flow 1 tokens have aud: <mcp-client-id> for MCP authentication
- Flow 2 tokens have aud: "nextcloud" for resource access
- Token Broker manages the exchange between audiences
"""
@@ -26,7 +26,7 @@ class ProgressiveConsentTokenVerifier:
Token verifier for Progressive Consent dual OAuth flows.
This verifier:
1. Validates Flow 1 tokens (aud: "mcp-server") for MCP authentication
1. Validates Flow 1 tokens (aud: <mcp-client-id>) for MCP authentication
2. Checks if user has provisioned Nextcloud access (Flow 2)
3. Uses Token Broker to obtain aud: "nextcloud" tokens when needed
"""
@@ -38,6 +38,7 @@ class ProgressiveConsentTokenVerifier:
oidc_discovery_url: Optional[str] = None,
nextcloud_host: Optional[str] = None,
encryption_key: Optional[str] = None,
mcp_client_id: Optional[str] = None,
):
"""
Initialize the Progressive Consent token verifier.
@@ -48,6 +49,7 @@ class ProgressiveConsentTokenVerifier:
oidc_discovery_url: OIDC provider discovery URL
nextcloud_host: Nextcloud server URL
encryption_key: Fernet key for token encryption
mcp_client_id: MCP server OAuth client ID for audience validation
"""
self.storage = token_storage
self.oidc_discovery_url = oidc_discovery_url or os.getenv(
@@ -56,6 +58,7 @@ class ProgressiveConsentTokenVerifier:
)
self.nextcloud_host = nextcloud_host or os.getenv("NEXTCLOUD_HOST")
self.encryption_key = encryption_key or os.getenv("TOKEN_ENCRYPTION_KEY")
self.mcp_client_id = mcp_client_id or os.getenv("OIDC_CLIENT_ID")
# Create token broker if not provided
if token_broker:
@@ -73,10 +76,10 @@ class ProgressiveConsentTokenVerifier:
async def verify_token(self, token: str) -> Optional[AccessToken]:
"""
Verify a Flow 1 token (aud: "mcp-server").
Verify a Flow 1 token (aud: <mcp-client-id>).
This validates that:
1. Token has correct audience for MCP server
1. Token has correct audience for MCP server (matches client ID)
2. Token is not expired
3. Token has valid signature (if verification enabled)
@@ -96,9 +99,11 @@ class ProgressiveConsentTokenVerifier:
if isinstance(audiences, str):
audiences = [audiences]
# Check for correct audience
if "mcp-server" not in audiences:
logger.warning(f"Token rejected: wrong audience {audiences}")
# Check for correct audience (must match MCP server client ID)
if self.mcp_client_id not in audiences:
logger.warning(
f"Token rejected: wrong audience {audiences}, expected {self.mcp_client_id}"
)
# Check if this is a Nextcloud token (wrong flow)
if "nextcloud" in audiences:
logger.error(
@@ -125,7 +130,7 @@ class ProgressiveConsentTokenVerifier:
client_id=client_id,
scopes=scopes,
expires_at=exp,
resource=f"user:{user_id}", # Store user_id in resource field
resource=user_id, # Store user_id in resource field (RFC 8707)
)
except jwt.InvalidTokenError as e:
@@ -63,7 +63,17 @@ def require_provisioning(func: Callable) -> Callable:
logger.debug("BasicAuth mode detected - skipping provisioning check")
return await func(*args, **kwargs)
# Progressive Consent mode - check if user has completed Flow 2 provisioning
# Check if we're in token exchange mode - if so, skip provisioning check
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
# Token exchange mode - per-request exchange, no provisioning needed
logger.debug("Token exchange mode detected - skipping provisioning check")
return await func(*args, **kwargs)
# Progressive Consent mode (offline access) - check if user has completed Flow 2 provisioning
# Get user_id from authorization token
user_id = None
if hasattr(ctx, "authorization") and ctx.authorization:
+154 -7
View File
@@ -28,6 +28,9 @@ logger = logging.getLogger(__name__)
class TokenExchangeService:
"""Implements RFC 8693 OAuth 2.0 Token Exchange."""
# RFC 8693 Grant Type
TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
# RFC 8693 Token Type Identifiers
TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"
TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt"
@@ -60,8 +63,9 @@ class TokenExchangeService:
self._discovery_cache_time: float = 0
self._discovery_cache_ttl: float = 3600 # 1 hour
# Initialize storage for checking provisioning
self.storage = RefreshTokenStorage()
# Storage for Progressive Consent (refresh tokens) - only needed for delegation
# NOT needed for pure RFC 8693 exchange (MCP tools)
self.storage: Optional[RefreshTokenStorage] = None
# Create HTTP client
self.http_client = httpx.AsyncClient(
@@ -71,7 +75,8 @@ class TokenExchangeService:
async def __aenter__(self):
"""Async context manager entry."""
await self.storage.initialize()
if self.storage:
await self.storage.initialize()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
@@ -83,6 +88,16 @@ class TokenExchangeService:
await self.http_client.aclose()
# RefreshTokenStorage doesn't have a close method
async def _ensure_storage(self):
"""Lazily initialize storage for Progressive Consent operations.
Only needed for delegation operations that use refresh tokens.
NOT needed for pure RFC 8693 exchange (MCP tools).
"""
if self.storage is None:
self.storage = RefreshTokenStorage.from_env()
await self.storage.initialize()
async def _discover_endpoints(self) -> Dict[str, Any]:
"""Discover OIDC endpoints from discovery URL.
@@ -178,9 +193,104 @@ class TokenExchangeService:
return delegated_token, expires_in
async def exchange_token_for_audience(
self,
subject_token: str,
requested_audience: str = "nextcloud",
requested_scopes: list[str] | None = None,
) -> Tuple[str, int]:
"""
Pure RFC 8693 token exchange (no refresh tokens required).
This implements stateless per-request token exchange where:
1. Client token has aud: <client-id> (e.g., "nextcloud-mcp-server")
2. Exchange for token with aud: "nextcloud" (for API access)
3. NO refresh tokens or provisioning required
Use case: All MCP tool calls (request-time operations).
NOT for background jobs (which use refresh tokens separately).
Args:
subject_token: Token being exchanged (from MCP client)
requested_audience: Target audience (usually "nextcloud")
requested_scopes: Optional scopes (may not be supported by all IdPs)
Returns:
Tuple of (access_token, expires_in)
Raises:
ValueError: If token validation fails
RuntimeError: If exchange fails
"""
# 1. Validate subject token (accepts both "mcp-server" and client_id)
await self._validate_flow1_token(subject_token)
# 2. Extract user ID for logging
user_id = self._extract_user_id(subject_token)
# 3. Discover token endpoint
discovery = await self._discover_endpoints()
token_endpoint = discovery.get("token_endpoint")
if not token_endpoint:
raise RuntimeError("No token endpoint found in discovery")
# 4. Build pure RFC 8693 exchange request (subject_token ONLY)
data = {
"grant_type": self.TOKEN_EXCHANGE_GRANT,
"subject_token": subject_token,
"subject_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
"requested_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
"audience": requested_audience,
}
# Add scopes if provided (may not be supported by all providers)
if requested_scopes:
data["scope"] = " ".join(requested_scopes)
# Add client credentials
if self.client_id and self.client_secret:
data["client_id"] = self.client_id
data["client_secret"] = self.client_secret
try:
# Perform exchange
logger.debug(f"Exchanging token for audience={requested_audience}")
response = await self.http_client.post(
token_endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
result = response.json()
access_token = result.get("access_token")
expires_in = result.get("expires_in", 300)
if not access_token:
raise RuntimeError("No access token in exchange response")
logger.info(
f"Pure RFC 8693 token exchange successful for user {user_id}: "
f"audience={requested_audience}, expires_in={expires_in}s"
)
return access_token, expires_in
except httpx.HTTPStatusError as e:
logger.error(f"Token exchange failed: {e.response.text}")
raise RuntimeError(f"Token exchange failed: {e}")
except Exception as e:
logger.error(f"Token exchange error: {e}")
raise
async def _validate_flow1_token(self, token: str):
"""Validate that token has correct audience for MCP server.
Accepts either:
- "mcp-server" (Progressive Consent legacy)
- self.client_id (external IdP, e.g., "nextcloud-mcp-server")
Args:
token: JWT token to validate
@@ -197,9 +307,14 @@ class TokenExchangeService:
if isinstance(audience, str):
audience = [audience]
if "mcp-server" not in audience:
# Accept either "mcp-server" (Progressive Consent) or client_id (external IdP)
valid_audiences = ["mcp-server"]
if self.client_id:
valid_audiences.append(self.client_id)
if not any(aud in audience for aud in valid_audiences):
raise ValueError(
f"Invalid token audience. Expected 'mcp-server', got {audience}"
f"Invalid token audience. Expected one of {valid_audiences}, got {audience}"
)
# Check expiration
@@ -247,6 +362,7 @@ class TokenExchangeService:
Returns:
True if provisioned, False otherwise
"""
await self._ensure_storage()
token_data = await self.storage.get_refresh_token(user_id)
return token_data is not None
@@ -259,6 +375,7 @@ class TokenExchangeService:
Returns:
Refresh token if found, None otherwise
"""
await self._ensure_storage()
token_data = await self.storage.get_refresh_token(user_id)
if token_data:
return token_data.get("refresh_token")
@@ -412,6 +529,9 @@ _token_exchange_service: Optional[TokenExchangeService] = None
async def get_token_exchange_service() -> TokenExchangeService:
"""Get or create the singleton token exchange service.
Note: Storage is initialized lazily only when needed for delegation operations.
Pure RFC 8693 exchange (MCP tools) doesn't require storage.
Returns:
TokenExchangeService instance
"""
@@ -419,7 +539,7 @@ async def get_token_exchange_service() -> TokenExchangeService:
if _token_exchange_service is None:
_token_exchange_service = TokenExchangeService()
await _token_exchange_service.storage.initialize()
# Storage is initialized lazily via _ensure_storage() when needed
return _token_exchange_service
@@ -427,7 +547,9 @@ async def get_token_exchange_service() -> TokenExchangeService:
async def exchange_token_for_delegation(
flow1_token: str, requested_scopes: list[str], requested_audience: str = "nextcloud"
) -> Tuple[str, int]:
"""Convenience function to exchange tokens.
"""Convenience function to exchange tokens (Progressive Consent with refresh tokens).
NOTE: This is for background jobs only. For MCP tool calls, use exchange_token_for_audience().
Args:
flow1_token: The MCP session token (aud: "mcp-server")
@@ -443,3 +565,28 @@ async def exchange_token_for_delegation(
requested_scopes=requested_scopes,
requested_audience=requested_audience,
)
async def exchange_token_for_audience(
subject_token: str,
requested_audience: str = "nextcloud",
requested_scopes: list[str] | None = None,
) -> Tuple[str, int]:
"""Convenience function for pure RFC 8693 token exchange (no refresh tokens).
Use this for ALL MCP tool calls (request-time operations).
Args:
subject_token: Token being exchanged (from MCP client)
requested_audience: Target audience (usually "nextcloud")
requested_scopes: Optional scopes (may not be supported by all IdPs)
Returns:
Tuple of (access_token, expires_in)
"""
service = await get_token_exchange_service()
return await service.exchange_token_for_audience(
subject_token=subject_token,
requested_audience=requested_audience,
requested_scopes=requested_scopes,
)
-3
View File
@@ -6,7 +6,6 @@ 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,
@@ -87,7 +86,6 @@ 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:
@@ -249,7 +247,6 @@ 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 = await get_client(ctx)