feat: Implement ADR-005 unified token verifier to eliminate token passthrough vulnerability

Replace two non-compliant token verifiers (NextcloudTokenVerifier and
ProgressiveConsentTokenVerifier) with a single UnifiedTokenVerifier that properly
validates token audiences per MCP Security Best Practices specification.

The previous implementation had a critical security vulnerability where tokens
intended for the MCP server were passed directly to Nextcloud APIs without
proper audience validation (token passthrough anti-pattern). This violates
OAuth 2.0 security principles and the MCP specification.

Changes:
- Add UnifiedTokenVerifier supporting two compliant modes:
  * Multi-audience mode (default): Validates tokens contain BOTH MCP and
    Nextcloud audiences, enabling direct use without exchange
  * Token exchange mode (opt-in): Validates MCP audience only, exchanges
    for Nextcloud tokens via RFC 8693 with caching to minimize latency

- Remove token passthrough vulnerability from context.py and context_helper.py
- Implement token exchange caching (5-minute TTL default) to reduce network calls
- Add required environment variables for audience validation:
  * NEXTCLOUD_MCP_SERVER_URL - MCP server URL (used as audience)
  * NEXTCLOUD_RESOURCE_URI - Nextcloud resource identifier
  * TOKEN_EXCHANGE_CACHE_TTL - Cache TTL for exchanged tokens

- Update docker-compose.yml with resource URI configuration for both OAuth modes
- Add comprehensive test suite (29 tests) covering both authentication modes
- Remove legacy NextcloudTokenVerifier and ProgressiveConsentTokenVerifier

Security improvements:
- Eliminates token passthrough anti-pattern
- Enforces proper audience separation between MCP and Nextcloud
- Complies with MCP Security Best Practices and RFC 8707/8693
- Maintains performance with token exchange caching

Test results: 65/65 unit tests passed, 5/5 smoke tests passed

🤖 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-05 18:53:14 +01:00
parent 28c2debf3e
commit 9fab6cb550
12 changed files with 1199 additions and 950 deletions
+88 -36
View File
@@ -1,6 +1,11 @@
"""Helper functions for extracting OAuth context from MCP requests."""
"""Helper functions for extracting OAuth context from MCP requests.
ADR-005 compliant implementation with token exchange caching.
"""
import hashlib
import logging
import time
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
@@ -11,35 +16,36 @@ from .token_exchange import exchange_token_for_audience
logger = logging.getLogger(__name__)
# Token exchange cache: token_hash -> (exchanged_token, expiry_timestamp)
_exchange_cache: dict[str, tuple[str, float]] = {}
def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
"""
Extract authenticated user context from MCP request and create NextcloudClient.
Create NextcloudClient for multi-audience mode (no exchange needed).
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.
ADR-005 Mode 1: Token already contains both MCP and Nextcloud audiences.
The UnifiedTokenVerifier validated both audiences are present, so we can
use the token directly without exchange.
Args:
ctx: MCP request context containing session info
base_url: Nextcloud base URL
Returns:
NextcloudClient configured with bearer token auth
NextcloudClient configured with multi-audience token
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
# Extract validated access token from MCP 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
logger.debug("Retrieved access token from request.user for OAuth request")
logger.debug("Retrieved multi-audience token from request.user")
else:
logger.error(
"OAuth authentication failed: No access token found in request"
@@ -47,16 +53,20 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
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
# UnifiedTokenVerifier stored the username here during validation
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}")
logger.debug(
f"Creating NextcloudClient for user {username} with multi-audience token "
f"(no exchange needed)"
)
# Create client with bearer token
# Token was already validated to have both audiences
# Can use directly without exchange
return NextcloudClient.from_token(
base_url=base_url, token=access_token.token, username=username
)
@@ -71,12 +81,19 @@ async def get_session_client_from_context(
ctx: Context, base_url: str
) -> NextcloudClient:
"""
Create NextcloudClient using RFC 8693 token exchange for session operations.
Create NextcloudClient using RFC 8693 token exchange with caching.
ADR-005 Mode 2: Exchange MCP token for Nextcloud token via RFC 8693.
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)
1. Extract MCP token from context (validated by UnifiedTokenVerifier)
2. Check cache for existing exchanged token
3. If not cached or expired, exchange via RFC 8693
4. Cache the exchanged token to minimize exchange frequency
5. Create client with exchanged token
CRITICAL: This is where token exchange happens, NOT in the verifier.
The verifier already validated the MCP audience; now we exchange for Nextcloud.
Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced
by the MCP server via @require_scopes decorator, not by the IdP. Therefore,
@@ -88,7 +105,7 @@ async def get_session_client_from_context(
base_url: Nextcloud base URL
Returns:
NextcloudClient configured with ephemeral delegated token
NextcloudClient configured with ephemeral exchanged token
Raises:
AttributeError: If context doesn't contain expected OAuth session data
@@ -96,43 +113,60 @@ async def get_session_client_from_context(
"""
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
# Extract MCP 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}")
mcp_token = access_token.token
username = access_token.resource # Username from UnifiedTokenVerifier
logger.debug(f"Retrieved MCP token for user: {username}")
else:
logger.error("No Flow 1 token found in request context")
logger.error("No MCP 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)")
# Check cache for existing exchanged token
cache_key = hashlib.sha256(mcp_token.encode()).hexdigest()
if cache_key in _exchange_cache:
cached_token, expiry = _exchange_cache[cache_key]
if time.time() < expiry:
logger.debug(
f"Using cached exchanged token (expires in {expiry - time.time():.1f}s)"
)
return NextcloudClient.from_token(
base_url=base_url, token=cached_token, username=username
)
else:
logger.debug("Cached token expired, removing from cache")
del _exchange_cache[cache_key]
# 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.
# Perform RFC 8693 token exchange
logger.info(f"Exchanging MCP token for Nextcloud API token (user: {username})")
# Exchange for Nextcloud resource URI audience
exchanged_token, expires_in = await exchange_token_for_audience(
subject_token=flow1_token,
requested_audience="nextcloud",
subject_token=mcp_token,
requested_audience=settings.nextcloud_resource_uri or "nextcloud",
requested_scopes=None, # Nextcloud doesn't support scopes
)
logger.info(f"Pure token exchange successful. Token expires in {expires_in}s")
logger.info(f"Token exchange successful. Token expires in {expires_in}s")
# Cache the exchanged token
# Use the minimum of exchange TTL and configured cache TTL
cache_ttl = min(expires_in, settings.token_exchange_cache_ttl)
_exchange_cache[cache_key] = (exchanged_token, time.time() + cache_ttl)
logger.debug(f"Cached exchanged token for {cache_ttl}s")
# Clean up expired cache entries
_cleanup_exchange_cache()
# 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
)
@@ -143,3 +177,21 @@ async def get_session_client_from_context(
except Exception as e:
logger.error(f"Token exchange failed: {e}")
raise RuntimeError(f"Token exchange required but failed: {e}") from e
def _cleanup_exchange_cache():
"""Remove expired entries from the token exchange cache."""
global _exchange_cache
now = time.time()
expired_keys = [k for k, (_, expiry) in _exchange_cache.items() if expiry <= now]
for key in expired_keys:
del _exchange_cache[key]
if expired_keys:
logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries")
def clear_exchange_cache():
"""Clear the entire token exchange cache. Useful for testing."""
global _exchange_cache
_exchange_cache.clear()
logger.debug("Token exchange cache cleared")