Merge pull request #745 from cbcoutinho/feat/strict-auth-allowlists
feat(auth): drop test-client defaults, add ALLOWED_MGMT_CLIENT allowlist
This commit is contained in:
@@ -66,7 +66,15 @@ class ClientRegistry:
|
||||
- http://localhost:* and http://127.0.0.1:* are allowed (native clients)
|
||||
- https:// redirect URIs are allowed (cloud clients)
|
||||
- http:// non-localhost redirect URIs are rejected with a warning
|
||||
|
||||
If the env var is unset or empty, the registry remains empty and
|
||||
``validate_client`` rejects every client_id (fail-closed). There are no
|
||||
built-in defaults; operators must opt-in clients explicitly.
|
||||
"""
|
||||
# NOTE: ALLOWED_MCP_CLIENTS and ALLOWED_MGMT_CLIENT are currently separate
|
||||
# env vars to keep the MCP-route and management-API auth surfaces
|
||||
# independent. These may be consolidated into a single env var later
|
||||
# once the deployment story stabilises.
|
||||
allowed_clients = os.getenv("ALLOWED_MCP_CLIENTS", "").strip()
|
||||
|
||||
if allowed_clients:
|
||||
@@ -123,9 +131,11 @@ class ClientRegistry:
|
||||
)
|
||||
logger.info(f"Registered static client: {entry}")
|
||||
|
||||
# Add well-known clients if not explicitly configured
|
||||
if not self._clients:
|
||||
self._add_well_known_clients()
|
||||
logger.warning(
|
||||
"Client registry is empty: ALLOWED_MCP_CLIENTS is unset or empty. "
|
||||
"All MCP-flow OAuth requests will be rejected until configured."
|
||||
)
|
||||
|
||||
def _get_client_name(self, client_id: str) -> str:
|
||||
"""Get human-readable name for client_id."""
|
||||
@@ -135,35 +145,9 @@ class ClientRegistry:
|
||||
"continue-dev": "Continue IDE Extension",
|
||||
"zed-editor": "Zed Editor",
|
||||
"vscode-mcp": "VS Code MCP Extension",
|
||||
"test-mcp-client": "Test MCP Client",
|
||||
}
|
||||
return known_names.get(client_id, client_id.replace("-", " ").title())
|
||||
|
||||
def _add_well_known_clients(self):
|
||||
"""Add well-known MCP clients for testing and development."""
|
||||
well_known = [
|
||||
MCPClientInfo(
|
||||
client_id="claude-desktop",
|
||||
name="Claude Desktop",
|
||||
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["*"],
|
||||
is_public=True,
|
||||
metadata={"vendor": "Anthropic"},
|
||||
),
|
||||
MCPClientInfo(
|
||||
client_id="test-mcp-client",
|
||||
name="Test MCP Client",
|
||||
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["*"],
|
||||
is_public=True,
|
||||
metadata={"purpose": "testing"},
|
||||
),
|
||||
]
|
||||
|
||||
for client in well_known:
|
||||
self._clients[client.client_id] = client
|
||||
logger.info(f"Registered well-known client: {client.client_id}")
|
||||
|
||||
def validate_client(
|
||||
self,
|
||||
client_id: str,
|
||||
|
||||
@@ -17,6 +17,7 @@ Key Design Principles:
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -97,6 +98,25 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
self.cache_ttl = 3600 # 1 hour default
|
||||
|
||||
# NOTE: ALLOWED_MCP_CLIENTS and ALLOWED_MGMT_CLIENT are currently separate
|
||||
# env vars to keep the MCP-route and management-API auth surfaces
|
||||
# independent. These may be consolidated into a single env var later
|
||||
# once the deployment story stabilises.
|
||||
self._allowed_mgmt_clients: frozenset[str] = frozenset(
|
||||
entry.strip()
|
||||
for entry in os.getenv("ALLOWED_MGMT_CLIENT", "").split(",")
|
||||
if entry.strip()
|
||||
)
|
||||
if not self._allowed_mgmt_clients:
|
||||
logger.warning(
|
||||
"ALLOWED_MGMT_CLIENT is unset or empty: management API will reject "
|
||||
"all requests until configured."
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Management API allowlist: {sorted(self._allowed_mgmt_clients)}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"UnifiedTokenVerifier initialized in {self.mode} mode. "
|
||||
f"MCP audience: {settings.oidc_client_id} or {settings.nextcloud_mcp_server_url}, "
|
||||
@@ -169,10 +189,11 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
token: Bearer token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid (regardless of audience), None otherwise
|
||||
AccessToken if valid AND issued by an allowlisted client, None otherwise
|
||||
"""
|
||||
# Check cache first (using separate cache key to avoid mixing with MCP tokens)
|
||||
cache_key = f"mgmt:{hashlib.sha256(token.encode()).hexdigest()}"
|
||||
access_token: AccessToken | None = None
|
||||
if cache_key in self._token_cache:
|
||||
userinfo, expiry = self._token_cache[cache_key]
|
||||
if time.time() < expiry:
|
||||
@@ -181,7 +202,7 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||
scope_string = userinfo.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
return AccessToken(
|
||||
access_token = AccessToken(
|
||||
token=token,
|
||||
client_id=userinfo.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
@@ -191,10 +212,23 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
else:
|
||||
del self._token_cache[cache_key]
|
||||
|
||||
oauth_token_cache_hits_total.labels(hit="false").inc()
|
||||
if access_token is None:
|
||||
oauth_token_cache_hits_total.labels(hit="false").inc()
|
||||
access_token = await self._verify_without_audience_check(token, cache_key)
|
||||
|
||||
# Verify token without audience check
|
||||
return await self._verify_without_audience_check(token, cache_key)
|
||||
if access_token is None:
|
||||
return None
|
||||
|
||||
# Enforce ALLOWED_MGMT_CLIENT allowlist (fail-closed when unset)
|
||||
token_client_id = access_token.client_id
|
||||
if not token_client_id or token_client_id not in self._allowed_mgmt_clients:
|
||||
logger.warning(
|
||||
"Management API token rejected: client_id %r not in ALLOWED_MGMT_CLIENT",
|
||||
token_client_id,
|
||||
)
|
||||
return None
|
||||
|
||||
return access_token
|
||||
|
||||
async def _verify_mcp_audience(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user