Skip to main content
Glama
ADR-005-token-audience-validation.md38.7 kB
# ADR-005: Token Audience Validation and Security Compliance **Status**: Implemented **Date**: 2025-01-05 **Updated**: 2025-11-05 **Related**: Issue #261, ADR-004, upstream-oauth.md, RFC 7519, RFC 8707, RFC 9728 **Supersedes**: Token passthrough mode in ADR-004 ## Implementation Note This ADR has been fully implemented with key simplifications based on RFC 7519 Section 4.1.3: - MCP server validates only its own audience (not Nextcloud's) - OAuth requests include `resource` parameter (RFC 8707) - Clients discover resource via PRM endpoint (RFC 9728) - Nextcloud OIDC app uses client-specific resource URLs ## Executive Summary This ADR addresses a critical security vulnerability where the MCP server was passing tokens intended for itself directly to Nextcloud APIs (token passthrough). We will: 1. **Replace two non-compliant token verifiers** with a single `UnifiedTokenVerifier` 2. **Implement proper audience validation** requiring tokens to explicitly include appropriate audiences 3. **Support two compliant modes**: - **Multi-audience mode (default)**: Tokens contain both MCP and Nextcloud audiences - **Token exchange mode (opt-in)**: MCP tokens are exchanged for Nextcloud tokens via RFC 8693 4. **Remove all token passthrough paths** to comply with MCP security specification The solution works within python-sdk constraints by implementing a two-layer architecture where token validation happens in the verifier and token exchange happens when creating API clients. ## Context The MCP Security Best Practices specification explicitly forbids "token passthrough" - an anti-pattern where an MCP server accepts tokens from clients without validating they were properly issued to the MCP server, then passes them through to downstream APIs. ### Current Vulnerability The Nextcloud MCP server currently supports two OAuth modes via the `ENABLE_TOKEN_EXCHANGE` flag: 1. **Pass-through mode** (`ENABLE_TOKEN_EXCHANGE=false`, **default**): - Accepts Flow 1 tokens with audience matching MCP server URL or client ID - Passes these tokens **directly** to Nextcloud APIs without audience transformation - **Violates MCP specification** - token intended for MCP server is used against Nextcloud 2. **Token exchange mode** (`ENABLE_TOKEN_EXCHANGE=true`, opt-in): - Accepts Flow 1 tokens with audience matching MCP server URL - Uses RFC 8693 to exchange for tokens with Nextcloud resource URI audience - **Compliant** with MCP specification but adds latency **Location of vulnerability**: `nextcloud_mcp_server/context.py:62-66` ### Security Risks (per MCP specification) 1. **Security Control Circumvention**: Downstream APIs cannot distinguish between clients when all use the same token 2. **Accountability Issues**: Broken audit trails - logs show wrong identity/source 3. **Trust Boundary Violations**: Token meant for one service used for another 4. **Future Compatibility**: Cannot add security controls later without breaking changes ### OAuth Feature Status The OAuth integration is currently **experimental** and requires an upstream fix in Nextcloud server to properly handle bearer tokens (see `docs/upstream-oauth.md` for details). Until the upstream fix is merged, **all breaking changes are acceptable** to ensure security compliance. ## Decision We will **remove the token passthrough anti-pattern entirely** and enforce proper token audience validation in all OAuth deployments. ### Architectural Approach Based on analysis of the existing code and python-sdk constraints, we will: 1. **Consolidate two non-compliant verifiers** (`NextcloudTokenVerifier` and `ProgressiveConsentTokenVerifier`) into a single `UnifiedTokenVerifier` 2. **Implement a two-layer architecture**: - **Verification Layer**: `UnifiedTokenVerifier` validates audiences only (complies with SDK protocol) - **Exchange Layer**: `context_helper.py` performs token exchange when needed 3. **Support two compliant modes** determined by the `ENABLE_TOKEN_EXCHANGE` setting: ### Mode 1: Multi-Audience Token Validation (Default) Use multi-audience tokens directly. Per RFC 7519 Section 4.1.3, resource servers validate only their own presence in the audience claim. The MCP server validates its own audience; Nextcloud independently validates its own audience when receiving API calls. This is the default mode when `ENABLE_TOKEN_EXCHANGE` is false or not set. **Requirements**: - Token must have `aud` claim containing MCP server audience: - Client ID OR - MCP server URL (e.g., `http://localhost:8001`) OR - MCP server URL with /mcp suffix (e.g., `http://localhost:8001/mcp`) - For Nextcloud API access to work, token should also include Nextcloud audience (validated by Nextcloud, not MCP) - Single token works for both MCP authentication and Nextcloud API access - IdP must support multi-audience tokens for full functionality **Resource URI Configuration**: - Nextcloud OIDC app: Set via `default_resource_identifier` (default: `http://localhost:8080`) - Keycloak: Configure resource servers with proper URIs - MCP Server: Defaults to `NEXTCLOUD_MCP_SERVER_URL` environment variable **Use case**: Standard deployments where IdP can issue tokens with multiple audiences **Configuration**: ```bash # Multi-audience mode (default when not set or false) ENABLE_TOKEN_EXCHANGE=false # or omit entirely # Resource URIs for audience validation NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 # MCP server URL (used as audience) NEXTCLOUD_RESOURCE_URI=http://localhost:8080 # Nextcloud resource identifier # Client ID (alternative audience for MCP) OIDC_CLIENT_ID=nextcloud-mcp-server ``` **Token validation logic (RFC 7519 compliant) - Actual Implementation**: ```python def _has_mcp_audience(self, payload: dict[str, Any]) -> bool: """ Check if token has MCP audience. Per RFC 7519 Section 4.1.3, resource servers should only validate their own presence in the audience claim. We don't validate Nextcloud's audience - that's Nextcloud's responsibility when it receives the token. """ audiences = payload.get("aud", []) if isinstance(audiences, str): audiences = [audiences] audiences_set = set(audiences) # MCP must have at least one: client_id OR server_url OR server_url/mcp return bool( self.settings.oidc_client_id in audiences_set or ( self.settings.nextcloud_mcp_server_url and ( self.settings.nextcloud_mcp_server_url in audiences_set or f"{self.settings.nextcloud_mcp_server_url}/mcp" in audiences_set ) ) ) ``` ### Mode 2: RFC 8693 Token Exchange (Opt-in) Exchange MCP session tokens for Nextcloud-specific tokens via RFC 8693. This mode is activated when `ENABLE_TOKEN_EXCHANGE=true`. **Requirements**: - Client provides token with MCP audience (client ID or server URL) - Server exchanges it for ephemeral token with Nextcloud resource URI - IdP must support RFC 8693 token exchange endpoint - Exchanged tokens cached for 5 minutes to reduce latency **Performance Consideration**: In the context of an agentic LLM application, the additional network call for token exchange (typically 50-100ms) is negligible compared to LLM inference time (seconds). The security benefit far outweighs the minimal latency cost. **Use case**: - Deployments requiring strict audience separation - IdPs with full RFC 8693 support (e.g., Keycloak with token exchange enabled) **Configuration**: ```bash # Token exchange mode (opt-in for strict separation) ENABLE_TOKEN_EXCHANGE=true # Resource URIs NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 # MCP server URL NEXTCLOUD_RESOURCE_URI=http://localhost:8080 # Nextcloud resource identifier # Optional: Cache TTL TOKEN_EXCHANGE_CACHE_TTL=300 # seconds (default: 300) # OIDC discovery URL (token endpoint is auto-discovered from this) OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration ``` **Token exchange with caching**: ```python class TokenExchangeCache: """Cache exchanged tokens to reduce exchange frequency.""" def __init__(self, ttl_seconds: int = 300): # 5-minute default self._cache: dict[str, tuple[str, float]] = {} self._ttl = ttl_seconds async def get_or_exchange( self, subject_token: str, token_hash: str, exchange_func: Callable ) -> str: """Get cached token or perform exchange.""" now = time.time() # Check cache if token_hash in self._cache: cached_token, expiry = self._cache[token_hash] if expiry > now: logger.debug(f"Using cached exchanged token (expires in {expiry - now:.1f}s)") return cached_token # Perform exchange logger.debug("Exchanging token for Nextcloud audience") nextcloud_token = await exchange_func( subject_token=subject_token, requested_audience=self.nextcloud_resource_uri ) # Cache with TTL self._cache[token_hash] = (nextcloud_token, now + self._ttl) # Clean expired entries self._cache = { k: v for k, v in self._cache.items() if v[1] > now } return nextcloud_token ``` ### Removed: Pass-through Mode (Non-compliant) The pass-through mode is **removed immediately** as it violates MCP security requirements. No migration period is provided since the OAuth feature is experimental. ## Implementation ### 1. Environment Variables **Required variables**: ```bash # Resource URIs (required for audience validation) NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 # MCP server URL (used as audience) NEXTCLOUD_RESOURCE_URI=http://localhost:8080 # Nextcloud resource identifier # Client identification OIDC_CLIENT_ID=nextcloud-mcp-server # Can also be valid audience for MCP ``` **Mode selection**: ```bash # Multi-audience mode (default) ENABLE_TOKEN_EXCHANGE=false # or omit entirely # Token exchange mode (opt-in) ENABLE_TOKEN_EXCHANGE=true # Activates RFC 8693 exchange ``` **Optional variables (exchange mode)**: ```bash TOKEN_EXCHANGE_CACHE_TTL=300 # Cache TTL in seconds (default: 300) ``` ### 2. Consolidate Token Verifiers **Current Issue**: Two TokenVerifier implementations exist (`NextcloudTokenVerifier` and `ProgressiveConsentTokenVerifier`), leading to code duplication, inconsistent validation logic, and pass-through vulnerabilities. **Solution**: Consolidate into a single `UnifiedTokenVerifier` class that handles both compliant validation modes: ```python class UnifiedTokenVerifier(TokenVerifier): """ Unified token verifier supporting both multi-audience and token exchange modes. Compliant with MCP security specification - no token pass-through. """ def __init__(self, settings: Settings): self.settings = settings self.mode = "exchange" if settings.enable_token_exchange else "multi-audience" # Common components self.http_client = httpx.AsyncClient(timeout=10.0) self.jwks_client = PyJWKClient(settings.jwks_uri) if settings.jwks_uri else None # Mode-specific initialization if self.mode == "exchange": # Exchange mode components (cache is in context helper, not here) self.introspection_uri = settings.introspection_uri self.client_secret = settings.oidc_client_secret logger.info(f"Token verifier initialized in {self.mode} mode") async def verify_token(self, token: str) -> AccessToken | None: """ Verify token according to MCP TokenVerifier protocol. Per RFC 7519, we validate only MCP audience. The mode determines what happens AFTER verification in context_helper.py: - Multi-audience mode: Use token directly (Nextcloud validates its own audience) - Exchange mode: Exchange for Nextcloud-audience token via RFC 8693 Args: token: Bearer token to verify Returns: AccessToken if valid with MCP audience, None otherwise """ # Check cache first cached = self._get_cached_token(token) if cached: logger.debug("Token found in cache") return cached # Both modes do the same validation (MCP audience only) return await self._verify_mcp_audience(token) async def _verify_mcp_audience(self, token: str) -> AccessToken | None: """ Validate token has MCP audience. Per RFC 7519 Section 4.1.3, resource servers validate only their own presence in the audience claim. We don't validate Nextcloud's audience - that's Nextcloud's responsibility when it receives the token. Args: token: Bearer token to verify Returns: AccessToken if valid with MCP audience, None otherwise """ try: # Attempt JWT verification first if self._is_jwt_format(token) and self.jwks_client: payload = await self._verify_jwt_signature(token) else: # Fall back to introspection for opaque tokens payload = await self._introspect_token(token) if not payload: return None # Validate MCP audience is present if not self._has_mcp_audience(payload): audiences = payload.get("aud", []) logger.error( f"Token rejected: Missing MCP audience. " f"Got {audiences}, need MCP ({self.settings.oidc_client_id} or " f"{self.settings.nextcloud_mcp_server_url})" ) return None # Log based on mode for clarity if self.mode == "multi-audience": logger.info( "MCP audience validated - token can be used directly " "(Nextcloud will validate its own audience)" ) else: logger.info( "MCP audience validated - token will be exchanged for Nextcloud access" ) return self._create_access_token(token, payload) ``` **Key Design Decisions**: 1. **Separation of Concerns**: The verifier ONLY validates tokens. Token exchange happens in `context_helper.py` when creating the NextcloudClient, not in the verifier itself. 2. **Python SDK Compatibility**: The MCP python-sdk's `TokenVerifier` protocol requires returning an `AccessToken` object. We comply with this interface while deferring exchange to the context layer. 3. **Mode Selection**: Single class with mode-based behavior selected at startup via `ENABLE_TOKEN_EXCHANGE` environment variable. **Benefits**: - Single source of truth for token validation logic - Clear separation between validation and exchange - Compliant with MCP TokenVerifier protocol - Eliminates token pass-through vulnerability - Consistent error handling across all modes ### 3. Error Handling and Propagation Token validation errors will be handled consistently: ```python class TokenValidationError(Exception): """Raised when token validation fails.""" def __init__(self, message: str, details: dict = None): super().__init__(message) self.details = details or {} self.http_status = 401 # Unauthorized async def _verify_jwt_token(self, token: str) -> AccessToken: """Verify JWT token with proper audience validation.""" try: payload = jwt.decode(token, options={"verify_signature": False}) except jwt.InvalidTokenError as e: raise TokenValidationError( "Invalid JWT token format", details={"error": str(e)} ) # Validate audiences if not await self.validate_token_audiences(payload, self.settings): raise TokenValidationError( "Token audiences do not meet requirements", details={ "got": payload.get("aud"), "need_mcp": [self.settings.oidc_client_id, self.settings.mcp_resource_uri], "need_nextcloud": self.settings.nextcloud_resource_uri } ) # Additional validation (expiry, issuer, etc.) # ... return AccessToken(...) ``` ### 4. Configuration Validation Startup validation ensures consistent configuration: ```python def validate_oauth_configuration(settings: Settings): """Validate OAuth configuration at startup.""" if not settings.nextcloud_mcp_server_url: raise ValueError("NEXTCLOUD_MCP_SERVER_URL is required for audience validation") if not settings.nextcloud_resource_uri: raise ValueError("NEXTCLOUD_RESOURCE_URI is required for audience validation") if settings.enable_token_exchange: logger.info("Token exchange mode enabled - using RFC 8693 for strict audience separation") if not settings.oidc_discovery_url: logger.warning( "No OIDC_DISCOVERY_URL configured. " "Token endpoint discovery may fail." ) else: logger.info("Multi-audience mode enabled - tokens must contain both MCP and Nextcloud audiences") ``` ### 5. OAuth Resource Parameters and PRM Discovery To ensure tokens have the correct audience, OAuth authorization requests must include the `resource` parameter (RFC 8707): **OAuth Authorization Requests**: ```python # Flow 1 (MCP Client Authentication) idp_params = { "client_id": idp_client_id, "redirect_uri": callback_uri, "response_type": "code", "scope": scopes, "state": idp_state, "prompt": "consent", "resource": f"{oauth_config['mcp_server_url']}/mcp", # MCP server audience } # Flow 2 (Nextcloud Resource Access) idp_params = { ... "resource": oauth_config["nextcloud_resource_uri"], # Nextcloud audience } ``` **Protected Resource Metadata (PRM) Endpoint**: The MCP server exposes PRM metadata at `/.well-known/oauth-protected-resource` (RFC 9728): ```json { "resource": "http://localhost:8001/mcp", "scopes_supported": ["notes:read", "notes:write", ...], "authorization_servers": ["http://localhost:8080"], "bearer_methods_supported": ["header"], "resource_signing_alg_values_supported": ["RS256"] } ``` **Client Discovery Pattern**: ```python # Clients should discover resource identifier from PRM prm_url = f"{mcp_server_url}/.well-known/oauth-protected-resource" async with httpx.AsyncClient() as client: prm_response = await client.get(prm_url, timeout=10) prm_data = prm_response.json() resource_identifier = prm_data.get("resource") # Use discovered resource in OAuth request auth_url = f"{authorization_endpoint}?resource={quote(resource_identifier, safe='')}&..." ``` ### 6. Context Helper Updates Update `context.py` to handle token exchange at the NextcloudClient creation point: ```python async def get_client(ctx: Context) -> NextcloudClient: """Get NextcloudClient based on authentication mode.""" settings = get_settings() lifespan_ctx = ctx.request_context.lifespan_context # BasicAuth mode - unchanged if hasattr(lifespan_ctx, "client"): return lifespan_ctx.client # OAuth mode if hasattr(lifespan_ctx, "nextcloud_host"): if settings.enable_token_exchange: # Mode 2: Exchange MCP token for Nextcloud token logger.debug("Token exchange mode - exchanging token") return await get_session_client_from_context( ctx, lifespan_ctx.nextcloud_host ) else: # Mode 1: Token already has both audiences, use directly logger.debug("Multi-audience mode - using token directly") return get_client_from_context(ctx, lifespan_ctx.nextcloud_host) raise AttributeError("Unknown context type") # In context_helper.py async def get_session_client_from_context( ctx: Context, base_url: str ) -> NextcloudClient: """ Create NextcloudClient using RFC 8693 token exchange. CRITICAL: This is where token exchange happens, NOT in the verifier. The verifier already validated the MCP audience; now we exchange for Nextcloud. """ # Extract validated MCP token from context access_token: AccessToken = ctx.request_context.request.user.access_token mcp_token = access_token.token username = access_token.resource # Username from verifier # 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("Using cached exchanged token") return NextcloudClient.from_token( base_url=base_url, token=cached_token, username=username ) # Perform RFC 8693 token exchange logger.info("Exchanging MCP token for Nextcloud API token") exchanged_token, expires_in = await exchange_token_for_audience( subject_token=mcp_token, requested_audience=settings.nextcloud_resource_uri, requested_scopes=None, # Nextcloud doesn't enforce scopes ) # Cache the exchanged token _exchange_cache[cache_key] = ( exchanged_token, time.time() + min(expires_in, settings.token_exchange_cache_ttl) ) # Create client with exchanged token return NextcloudClient.from_token( base_url=base_url, token=exchanged_token, username=username ) def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient: """ Create NextcloudClient for multi-audience mode (no exchange needed). Token already contains both MCP and Nextcloud audiences. """ access_token: AccessToken = ctx.request_context.request.user.access_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=access_token.resource # Username from verifier ) ``` **Key Implementation Details**: 1. **Token Exchange Location**: Exchange happens in `get_session_client_from_context()`, not in the verifier 2. **Caching**: Exchange cache is maintained in the context helper to prevent repeated exchanges 3. **Python SDK Integration**: We work with the SDK's `AccessToken` object and create `NextcloudClient` with the appropriate token ### 6. Performance Benchmarks Expected performance characteristics: | Mode | Latency Impact | Use Case | |------|---------------|----------| | Multi-Audience | 0ms (no extra calls) | Default, best performance | | Token Exchange (cached) | ~1ms (cache lookup) | Recently used tokens | | Token Exchange (fresh) | 50-100ms (network call) | First use or after cache expiry | In context of LLM operations: - LLM inference: 2-10 seconds typical - Token exchange: 0.05-0.1 seconds (1-2% of total request time) - **Conclusion**: Performance impact is negligible ### 7. IdP Configuration Examples #### Nextcloud Built-in OIDC (Multi-Audience) ```bash # Set resource identifier for Nextcloud php occ config:app:set oidc default_resource_identifier --value="http://localhost:8080" # MCP server configuration (multi-audience mode) ENABLE_TOKEN_EXCHANGE=false # or omit NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 NEXTCLOUD_RESOURCE_URI=http://localhost:8080 ``` #### Keycloak with Multi-Audience ```bash # 1. Create resource servers in Keycloak # Admin Console > Clients > Create Client # - MCP Resource Server: http://localhost:8000 # - Nextcloud Resource Server: http://localhost:8080 # 2. Configure token mapper for multi-audience # Client > Mappers > Create # - Mapper Type: Audience # - Included Client Audience: Select both resource servers # 3. MCP server configuration ENABLE_TOKEN_EXCHANGE=false # Multi-audience mode NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 NEXTCLOUD_RESOURCE_URI=http://localhost:8080 OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration ``` #### Keycloak with Token Exchange ```bash # 1. Enable token exchange in Keycloak # Realm Settings > Client Policies > Add permission for token-exchange # 2. MCP server configuration ENABLE_TOKEN_EXCHANGE=true # Exchange mode NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 NEXTCLOUD_RESOURCE_URI=http://localhost:8080 OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration # Note: Token endpoint is auto-discovered from the OIDC discovery URL ``` ## Testing ### Unit Tests ```python @pytest.mark.unit async def test_multi_audience_validation(): """Test multi-audience token validation logic.""" validator = UnifiedTokenVerifier( nextcloud_mcp_server_url="http://localhost:8000", nextcloud_resource_uri="http://localhost:8080", oidc_client_id="test-client" ) # Valid: Both resource URIs token = {"aud": ["http://localhost:8000", "http://localhost:8080"]} assert await validator.validate_token_audiences(token) # Valid: Client ID + Nextcloud URI token = {"aud": ["test-client", "http://localhost:8080"]} assert await validator.validate_token_audiences(token) # Invalid: Missing Nextcloud token = {"aud": ["http://localhost:8000"]} assert not await validator.validate_token_audiences(token) # Invalid: Missing MCP token = {"aud": ["http://localhost:8080"]} assert not await validator.validate_token_audiences(token) @pytest.mark.unit async def test_token_exchange_caching(): """Test token exchange caching behavior.""" cache = TokenExchangeCache(ttl_seconds=5) exchange_count = 0 async def mock_exchange(subject_token: str, requested_audience: str): nonlocal exchange_count exchange_count += 1 return f"exchanged-{exchange_count}" # First call - should exchange token1 = await cache.get_or_exchange("subject-1", "hash-1", mock_exchange) assert token1 == "exchanged-1" assert exchange_count == 1 # Second call with same hash - should use cache token2 = await cache.get_or_exchange("subject-1", "hash-1", mock_exchange) assert token2 == "exchanged-1" assert exchange_count == 1 # No new exchange # Different hash - should exchange token3 = await cache.get_or_exchange("subject-2", "hash-2", mock_exchange) assert token3 == "exchanged-2" assert exchange_count == 2 ``` ### Integration Tests ```python @pytest.mark.integration async def test_multi_audience_e2e(nc_mcp_oauth_client): """Test end-to-end multi-audience token flow.""" # Token should have both audiences result = await nc_mcp_oauth_client.call_tool("nc_notes_list_notes") assert result.success # Verify token was not exchanged (check logs) logs = await get_server_logs() assert "Token exchange" not in logs assert "Multi-audience validation passed" in logs @pytest.mark.integration async def test_token_exchange_e2e(nc_mcp_keycloak_client): """Test end-to-end token exchange flow.""" # Start with MCP-only token result = await nc_mcp_keycloak_client.call_tool("nc_notes_list_notes") assert result.success # Verify exchange happened logs = await get_server_logs() assert "Exchanging token for Nextcloud audience" in logs # Second call should use cache result2 = await nc_mcp_keycloak_client.call_tool("nc_notes_list_notes") assert result2.success logs2 = await get_server_logs() assert "Using cached exchanged token" in logs2 @pytest.mark.integration async def test_invalid_audience_rejection(nc_mcp_oauth_client): """Test that invalid audiences are rejected with clear errors.""" # Manually inject token with wrong audience invalid_token = create_test_token(aud=["wrong-audience"]) with pytest.raises(TokenValidationError) as exc_info: await nc_mcp_oauth_client.call_tool( "nc_notes_list_notes", token=invalid_token ) assert exc_info.value.http_status == 401 assert "Token audiences do not meet requirements" in str(exc_info.value) assert exc_info.value.details["need_nextcloud"] == "http://localhost:8080" ``` ### Load Tests ```python @pytest.mark.load async def test_token_validation_performance(): """Benchmark token validation overhead.""" # Test both modes under load results = {} for enable_exchange in [False, True]: os.environ["ENABLE_TOKEN_EXCHANGE"] = str(enable_exchange).lower() mode = "exchange" if enable_exchange else "multi-audience" start = time.time() await run_concurrent_requests( num_workers=50, requests_per_worker=100, operation="nc_notes_list_notes" ) duration = time.time() - start results[mode] = { "total_time": duration, "requests_per_second": 5000 / duration, "avg_latency_ms": (duration / 5000) * 1000 } # Multi-audience should be faster (no exchange) assert results["multi-audience"]["avg_latency_ms"] < results["exchange"]["avg_latency_ms"] # But both should be acceptable for LLM context assert results["exchange"]["avg_latency_ms"] < 200 # Max 200ms overhead ``` ## Troubleshooting ### Common Issues and Solutions 1. **"Token audiences do not meet requirements"** - Check token with jwt.io to see actual audiences - Verify `NEXTCLOUD_MCP_SERVER_URL` and `NEXTCLOUD_RESOURCE_URI` match IdP configuration - For Nextcloud OIDC: Check `occ config:app:get oidc default_resource_identifier` 2. **"Token exchange failed"** - Verify IdP supports RFC 8693 token exchange - Check that OIDC discovery URL is correctly configured - Verify token endpoint is accessible from the MCP server - Enable debug logging: `LOG_LEVEL=DEBUG` 3. **"Configuration validation failed at startup"** - Ensure `ENABLE_TOKEN_EXCHANGE` is set correctly (true for exchange mode, false/omit for multi-audience) - Both resource URIs must be configured (`NEXTCLOUD_MCP_SERVER_URL` and `NEXTCLOUD_RESOURCE_URI`) - Check that IdP is configured to issue tokens with appropriate audiences 4. **Performance issues with exchange mode** - Check cache hit rate in logs - Increase `TOKEN_EXCHANGE_CACHE_TTL` if tokens are long-lived - Consider switching to multi-audience mode if IdP supports it ### Debug Commands ```bash # Check current token audiences (requires jq) echo $ACCESS_TOKEN | cut -d. -f2 | base64 -d | jq '.aud' # Test multi-audience validation curl -X POST http://localhost:8000/mcp/v1/tools/nc_notes_list_notes \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" # Check server logs for validation details docker compose logs mcp-oauth | grep -E "(audience|validation|exchange)" # Verify IdP resource configuration (Keycloak) curl http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration | jq '.resource_servers' ``` ## Security Considerations ### Threat Model **Threat**: Malicious client uses stolen MCP token against Nextcloud directly - **Mitigation**: Tokens must contain correct resource URI audiences - **Multi-Audience**: Requires token with both audiences (harder to obtain) - **Exchange**: MCP token cannot be used directly against Nextcloud **Threat**: Token reuse across services - **Mitigation**: Strict audience validation ensures tokens only work for intended services - **Validation**: Both MCP and Nextcloud validate their respective audiences **Threat**: Audit trail confusion - **Mitigation**: Clear separation of token contexts - **Multi-Audience**: Different audience claims identify service context - **Exchange**: Completely different tokens for each service ### Compliance This implementation ensures **full compliance** with: - [MCP Security Best Practices - Token Passthrough](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#token-passthrough) - OAuth 2.0 Resource Indicators (RFC 8707) - OAuth 2.0 Token Exchange (RFC 8693) ## Migration Guide ### For Existing Deployments **BREAKING CHANGE**: All OAuth deployments must be reconfigured to comply with the new audience validation requirements. #### Step 1: Update Environment Variables Add the required resource URI configuration: ```bash # Required for all OAuth modes NEXTCLOUD_MCP_SERVER_URL=http://your-mcp-server:8000 # Your MCP server URL NEXTCLOUD_RESOURCE_URI=http://your-nextcloud:8080 # Your Nextcloud instance URL ``` #### Step 2: Choose Your Mode **Option A: Multi-Audience Mode (Recommended for most deployments)** ```bash ENABLE_TOKEN_EXCHANGE=false # or omit entirely ``` Configure your IdP to issue tokens with both audiences: - MCP audience: Your client ID or MCP server URL - Nextcloud audience: Your Nextcloud resource URI **Option B: Token Exchange Mode (For strict separation)** ```bash ENABLE_TOKEN_EXCHANGE=true TOKEN_EXCHANGE_CACHE_TTL=300 # Optional, default is 300 seconds ``` Configure your IdP to: - Issue tokens with MCP audience only - Support RFC 8693 token exchange #### Step 3: Update IdP Configuration **For Nextcloud OIDC**: ```bash # Set the resource identifier docker compose exec app php occ config:app:set oidc default_resource_identifier --value="http://your-nextcloud:8080" ``` **For Keycloak**: 1. Create resource servers for both MCP and Nextcloud 2. Configure audience mappers appropriately 3. Enable token exchange if using exchange mode #### Step 4: Test Your Configuration ```bash # Test multi-audience validation curl -X POST http://localhost:8000/mcp/v1/tools/nc_notes_list_notes \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" # Check logs for validation details docker compose logs mcp-oauth | grep -E "(audience|validation)" ``` ### Code Migration If you have custom code using the old verifiers: **Before**: ```python from nextcloud_mcp_server.auth.token_verifier import NextcloudTokenVerifier verifier = NextcloudTokenVerifier(...) ``` **After**: ```python from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier verifier = UnifiedTokenVerifier(settings) ``` ## Consequences ### Positive 1. **Security Compliance**: Eliminates token passthrough vulnerability 2. **OAuth Spec Compliance**: Follows RFC 7519 Section 4.1.3 - resource servers validate only their own audience 3. **Clear Architecture**: Explicit validation modes with resource URI semantics 4. **Performance**: Negligible impact in LLM context (1-2% of request time) 5. **Flexibility**: Supports both simple (multi-audience) and strict (exchange) modes 6. **Audit Trail**: Proper audience separation enables accurate logging 7. **Simpler Logic**: Each resource server independently validates its own audience, reducing complexity ### Negative 1. **Breaking Change**: Existing deployments must reconfigure 2. **Configuration Required**: Must specify resource URIs explicitly 3. **IdP Requirements**: Requires proper resource server configuration ### Neutral 1. **Experimental Status**: Breaking changes acceptable until upstream fix merged 2. **Performance Trade-off**: Security benefit outweighs minimal latency cost ## References - [Issue #261: Avoid Token Passthrough in OAuth flow](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/261) - [MCP Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices) - [RFC 8693: OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693) - [RFC 8707: Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) - [ADR-004: Federated Authentication Architecture](./ADR-004-mcp-application-oauth.md) - [Upstream OAuth Requirements](./upstream-oauth.md) ## Python SDK Constraints and Architecture ### SDK TokenVerifier Protocol The MCP python-sdk defines a strict `TokenVerifier` protocol that our implementation must follow: ```python class TokenVerifier(Protocol): async def verify_token(self, token: str) -> AccessToken | None: """Verify a bearer token and return access info if valid.""" ``` **Key Constraints**: 1. **Single Method Interface**: The verifier can only validate tokens, not modify or exchange them 2. **Return Type**: Must return an `AccessToken` object or `None` 3. **Token Access**: The original bearer token is passed through the SDK to API calls unless we intervene at a different layer ### Architecture Decisions Given these constraints, we implement a **two-layer architecture**: 1. **Token Verifier Layer** (`UnifiedTokenVerifier`): - Validates token audiences according to configured mode - Returns `AccessToken` objects to satisfy SDK protocol - Does NOT perform token exchange 2. **Context Helper Layer** (`context_helper.py`): - Extracts tokens from MCP context - Performs RFC 8693 token exchange when needed - Creates `NextcloudClient` with appropriate token - Maintains exchange cache to minimize latency This separation ensures: - Compliance with MCP SDK protocol - Clean separation of concerns - Token exchange happens only when creating API clients - Pass-through vulnerability is eliminated ## Implementation Checklist - [x] Create `UnifiedTokenVerifier` class replacing both existing verifiers - [x] Remove pass-through mode from `context.py` entirely - [x] Update `context_helper.py` to implement token exchange with caching - [x] Implement RFC 7519 compliant validation in unified verifier (MCP audience only) - [x] Add token exchange caching mechanism in context helper layer - [x] Add OAuth resource parameters to authorization requests (RFC 8707) - [x] Implement PRM endpoint for resource discovery (RFC 9728) - [x] Update tests to discover resource from PRM endpoint - [x] Fix Nextcloud OIDC app to use client-specific resource_url - [x] Update docker-compose.yml with resource URI configuration: - `NEXTCLOUD_MCP_SERVER_URL` (required) - `NEXTCLOUD_RESOURCE_URI` (required) - `TOKEN_EXCHANGE_CACHE_TTL` (optional, default: 300) - [x] Configure Nextcloud OIDC `default_resource_identifier` - [ ] Configure Keycloak resource servers with proper audiences - [x] Remove `NextcloudTokenVerifier` class - [x] Remove `ProgressiveConsentTokenVerifier` class - [x] Write unit tests for unified verifier - [x] Write integration tests for OAuth flows - [x] Update documentation with IdP configuration guides - [ ] Add performance benchmarks to CI pipeline - [ ] Update CHANGELOG.md with breaking changes notice

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/No-Smoke/nextcloud-mcp-comprehensive'

If you have feedback or need assistance with the MCP directory API, please join our Discord server