# 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