Merge pull request #908 from cbcoutinho/docs/login-flow-static-oidc-client-907

docs(login-flow): require static OIDC client; remove dead token-exchange mode
This commit is contained in:
Chris Coutinho
2026-06-14 17:43:54 +02:00
committed by GitHub
27 changed files with 182 additions and 1017 deletions
+14 -31
View File
@@ -2,15 +2,12 @@
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): 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
with a single implementation using multi-audience validation: it validates the MCP
audience per RFC 7519 (resource servers validate only their own audience), and
Nextcloud independently validates its own audience when it receives the token.
Key Design Principles:
- 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)
"""
@@ -39,18 +36,14 @@ logger = logging.getLogger(__name__)
class UnifiedTokenVerifier(TokenVerifier):
"""
Unified token verifier supporting both multi-audience and token exchange modes.
Unified token verifier for multi-audience tokens (ADR-005).
Compliant with MCP security specification - no token pass-through.
This verifier:
1. Validates tokens using JWT verification with JWKS or introspection fallback
2. Enforces proper audience validation based on configured mode
2. Enforces MCP audience validation (per RFC 7519); Nextcloud independently
validates its own audience when receiving API calls
3. Caches successful validations to avoid repeated API calls
Mode Selection (via ENABLE_TOKEN_EXCHANGE setting):
- 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):
@@ -61,7 +54,6 @@ class UnifiedTokenVerifier(TokenVerifier):
settings: Application settings containing OAuth configuration
"""
self.settings = settings
self.mode = "multi-audience"
# Common components for all modes
self.http_client = nextcloud_httpx_client(timeout=10.0)
@@ -118,8 +110,7 @@ class UnifiedTokenVerifier(TokenVerifier):
)
logger.info(
"UnifiedTokenVerifier initialized in %s mode. MCP audience: %s or %s, Nextcloud resource URI: %s, Valid issuers: %s",
self.mode,
"UnifiedTokenVerifier initialized (multi-audience). MCP audience: %s or %s, Nextcloud resource URI: %s, Valid issuers: %s",
settings.oidc_client_id,
settings.nextcloud_mcp_server_url,
settings.nextcloud_resource_uri,
@@ -130,10 +121,9 @@ class UnifiedTokenVerifier(TokenVerifier):
"""
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
Per RFC 7519, we validate only MCP audience. The token is then used
directly against Nextcloud (which validates its own audience) — see
context_helper.py.
Args:
token: Bearer token to verify
@@ -150,7 +140,6 @@ class UnifiedTokenVerifier(TokenVerifier):
oauth_token_cache_hits_total.labels(hit="false").inc()
# Both modes do the same validation (MCP audience only)
return await self._verify_mcp_audience(token)
async def verify_token_for_management_api(self, token: str) -> AccessToken | None:
@@ -292,16 +281,10 @@ class UnifiedTokenVerifier(TokenVerifier):
record_oauth_token_validation(validation_method, "invalid")
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"
)
logger.info(
"MCP audience validated - token can be used directly "
"(Nextcloud will validate its own audience)"
)
return self._create_access_token(token, payload)
-6
View File
@@ -57,7 +57,6 @@ _DEFAULTS: dict[str, Any] = {
"enable_background_operations": False,
"vector_sync_enabled": False,
"enable_offline_access": False,
"enable_token_exchange": False,
# Token storage
"token_encryption_key": None,
# None = ephemeral per-process tempfile (see get_token_db_path()).
@@ -1268,7 +1267,6 @@ def _is_multi_user_mode() -> bool:
- Multi-user BasicAuth (MCP_DEPLOYMENT_MODE=multi_user_basic)
- Login Flow v2 / default OAuth (MCP_DEPLOYMENT_MODE=login_flow, or no
username/password and no explicit mode)
- OAuth Token Exchange (ENABLE_TOKEN_EXCHANGE=true)
Single-user mode is:
- Single-user BasicAuth (username and password both set)
@@ -1285,10 +1283,6 @@ def _is_multi_user_mode() -> bool:
if explicit_mode == "single_user_basic":
return False
# Token exchange implies OAuth multi-user
if _dynaconf.get("ENABLE_TOKEN_EXCHANGE", False):
return True
# If both username and password are set, it's single-user BasicAuth
has_username = bool(_dynaconf.get("NEXTCLOUD_USERNAME"))
has_password = bool(_dynaconf.get("NEXTCLOUD_PASSWORD"))