fix: Simplify token verifier to be RFC 7519 compliant

Per RFC 7519 Section 4.1.3, resource servers should only validate their
own presence in the audience claim, not check for other resource servers.

Changes:
- UnifiedTokenVerifier now validates only MCP audience (not Nextcloud's)
- Nextcloud independently validates its own audience when receiving API calls
- This is NOT token passthrough (we validate tokens before use)
- This IS token reuse which is explicitly allowed by RFC 8707

Updates:
- Simplified _validate_multi_audience() to follow OAuth spec
- Updated docstrings and comments to clarify RFC 7519 compliance
- Fixed unit tests that expected dual-audience validation
- Updated ADR-005 to document the correct OAuth interpretation
- All tests pass: unit (65), smoke (5), OAuth integration

This makes the implementation simpler, more maintainable, and properly
aligned with OAuth 2.0 specifications while maintaining security.

🤖 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 21:44:04 +01:00
parent 877c4c91e0
commit 7d9ab5559c
6 changed files with 726 additions and 54 deletions
+28 -22
View File
@@ -4,13 +4,15 @@ Unified Token Verifier for ADR-005 Token Audience Validation.
This module replaces both NextcloudTokenVerifier and ProgressiveConsentTokenVerifier
with a single implementation that supports two compliant OAuth modes:
1. Multi-audience mode (default): Tokens must contain BOTH MCP and Nextcloud audiences
1. Multi-audience mode (default): Validates MCP audience per RFC 7519 (resource servers
validate only their own audience). Nextcloud independently validates its own audience.
2. Token exchange mode (opt-in): Tokens have MCP audience only, exchanged for Nextcloud tokens
Key Design Principles:
- Token verification happens HERE (validates audiences)
- Token verification happens HERE (validates MCP audience per OAuth spec)
- Token exchange happens in context_helper.py (when creating NextcloudClient)
- No token passthrough allowed (complies with MCP Security Specification)
- Token reuse IS allowed for multi-audience tokens (RFC 8707)
"""
import hashlib
@@ -39,8 +41,9 @@ class UnifiedTokenVerifier(TokenVerifier):
3. Caches successful validations to avoid repeated API calls
Mode Selection (via ENABLE_TOKEN_EXCHANGE setting):
- False/omit (default): Multi-audience mode - requires BOTH MCP and Nextcloud audiences
- True: Exchange mode - requires MCP audience only (exchange happens later)
- False/omit (default): Multi-audience mode - validates MCP audience only (per RFC 7519).
Nextcloud independently validates its own audience when receiving API calls.
- True: Exchange mode - requires MCP audience only, then exchanges for Nextcloud token
"""
def __init__(self, settings: Settings):
@@ -90,7 +93,7 @@ class UnifiedTokenVerifier(TokenVerifier):
CRITICAL: This method only validates tokens - it does NOT perform exchange.
Token exchange happens later in context_helper.py when creating NextcloudClient.
Multi-audience mode: Validates token has BOTH MCP and Nextcloud audiences
Multi-audience mode: Validates token has MCP audience (per RFC 7519)
Exchange mode: Validates token has MCP audience ONLY (exchange happens later)
Args:
@@ -115,14 +118,17 @@ class UnifiedTokenVerifier(TokenVerifier):
async def _verify_multi_audience_token(self, token: str) -> AccessToken | None:
"""
Validate token has both MCP and Nextcloud audiences (Mode 1).
Validate token has MCP audience (Mode 1).
Token can be used directly without exchange.
Per RFC 7519, we only validate our own (MCP) audience. Nextcloud will
independently validate its own audience when it receives the token.
Args:
token: Bearer token to verify
Returns:
AccessToken if valid with both audiences, None otherwise
AccessToken if valid with MCP audience, None otherwise
"""
try:
# Attempt JWT verification first
@@ -138,19 +144,18 @@ class UnifiedTokenVerifier(TokenVerifier):
if not payload:
return None
# Validate both audiences are present
# Validate MCP audience is present
if not self._validate_multi_audience(payload):
audiences = payload.get("aud", [])
logger.error(
f"Token rejected: Missing required audiences. "
f"Got {audiences}, need both MCP ({self.settings.oidc_client_id} or "
f"{self.settings.nextcloud_mcp_server_url}) AND Nextcloud "
f"({self.settings.nextcloud_resource_uri})"
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
logger.info(
"Multi-audience validation passed - token has both MCP and Nextcloud audiences"
"MCP audience validation passed - token authorized for MCP server"
)
return self._create_access_token(token, payload)
@@ -204,13 +209,20 @@ class UnifiedTokenVerifier(TokenVerifier):
def _validate_multi_audience(self, payload: dict[str, Any]) -> bool:
"""
Check if token has both MCP and Nextcloud audiences.
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.
This is NOT token passthrough (we validate the token). This IS token reuse
which is allowed by RFC 8707 for multi-audience tokens between trusted services.
Args:
payload: Decoded token payload
Returns:
True if both audiences present, False otherwise
True if MCP audience present, False otherwise
"""
audiences = payload.get("aud", [])
if isinstance(audiences, str):
@@ -227,13 +239,7 @@ class UnifiedTokenVerifier(TokenVerifier):
)
)
# Nextcloud must have its resource URI
nextcloud_valid = bool(
self.settings.nextcloud_resource_uri
and self.settings.nextcloud_resource_uri in audiences_set
)
return bool(mcp_valid and nextcloud_valid)
return bool(mcp_valid)
def _has_mcp_audience(self, payload: dict[str, Any]) -> bool:
"""