Files
Home-Assistant/custom_components/ha_mcp_tools/mcp_webhook.py
T
2026-07-17 10:12:15 -04:00

590 lines
24 KiB
Python

"""Webhook ingress for the in-process ha-mcp server (issue #1527).
Ported from the proven webhook-proxy add-on (``mcp_proxy``): an HA webhook
(``/api/webhook/<id>``) forwards MCP traffic to the loopback server and streams
the response back, so the server is reachable through Nabu Casa remote UI (or any
reverse proxy) with the webhook id as the shared secret.
Two auth postures, chosen in the options flow:
* ``none`` — the secret webhook URL *is* the credential (matches the add-on's
default). No bearer is required.
* ``ha_auth`` — Home Assistant core is the OAuth authorization server. This
module serves the RFC 8414 / RFC 9728 discovery documents (so claude.ai /
ChatGPT can sign in with the user's HA account) and validates inbound bearer
tokens via ``hass.auth``. There is no bespoke authorization-server code here —
every protocol step is HA core's own ``/auth/*``.
The forwarding handler mirrors ``mcp_proxy._handle_webhook`` exactly (hop-by-hop
header stripping, the SSE streaming branch with anti-buffering headers, the
content-type whitelist, ``Mcp-Session-Id`` propagation, and the 502/500 error
mapping); the ``ha_auth`` bearer check + discovery documents mirror the add-on's
``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``.
"""
from __future__ import annotations
import inspect
import logging
from contextlib import suppress
from typing import TYPE_CHECKING, Any
import aiohttp
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.webhook import async_register, async_unregister
from homeassistant.core import HomeAssistant
from .const import (
DATA_WEBHOOK,
DATA_WEBHOOK_ID,
DOMAIN,
OAUTH_BASE,
WEBHOOK_AUTH_HA,
WEBHOOK_AUTH_NONE,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
_LOGGER = logging.getLogger(__name__)
# Human-readable webhook name shown in the HA webhook registry.
_WEBHOOK_NAME = "HA-MCP in-process server"
# Hop-by-hop / sensitive request headers never forwarded upstream (identical set
# to mcp_proxy). ``authorization`` is stripped because the server authenticates
# to HA with its own provisioned token, not the caller's bearer.
_STRIPPED_REQUEST_HEADERS = frozenset(
{
"host",
"content-length",
"transfer-encoding",
"connection",
"cookie",
"authorization",
}
)
# Content-Types the forwarded response may carry as-is; anything else is coerced
# to JSON to prevent HTML injection / XSS through the proxy. ``text/plain`` is
# safe (a browser never executes it) and lets the server's friendly landing page
# — a plain-text 405 shown when a browser GETs the endpoint — render as text
# instead of a mislabeled JSON blob. ``text/html`` and friends stay coerced.
_ALLOWED_CONTENT_TYPES = ("application/json", "text/event-stream", "text/plain")
# Long timeout for streamed MCP responses (matches mcp_proxy).
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(total=300, sock_connect=10, sock_read=300)
# TOP-LEVEL hass.data flag recording that the ha_auth discovery views are bound
# for this HA session. Deliberately NOT under DOMAIN so it survives
# async_unload_entry's teardown — aiohttp cannot unregister an HTTP view until HA
# restarts, so the views (and this ownership flag) must outlive the config entry.
_OAUTH_VIEWS_REGISTERED_KEY = "ha_mcp_tools_oauth_metadata_views_registered"
# ---------------------------------------------------------------------------
# ha_auth resource server (HA core is the OAuth authorization server)
# ---------------------------------------------------------------------------
def _build_base_url(request: web.Request) -> str:
"""Build the public base URL from the request (host-derived).
ha_auth is always host-derived so the SAME install works via the Nabu Casa
cloud URL AND any other external URL. Reads ``X-Forwarded-Proto/Host`` as
sent: HA's forwarded middleware only validates proxy headers when
``X-Forwarded-For`` is present, so these can reach us raw. A peer can
thereby only shape the discovery/WWW-Authenticate URLs in its OWN
response (no cross-user vector), which is within SECURITY.md's
local-network trust model; treat stricter proxy validation as optional
hardening.
"""
host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host", "")
scheme = request.headers.get("X-Forwarded-Proto", request.scheme)
return f"{scheme}://{host}"
def _authorization_server_document(base: str) -> dict[str, Any]:
"""RFC 8414 authorization-server metadata pointing at HA core's OAuth.
Advertises HA core's own ``/auth/authorize`` + ``/auth/token`` as a public
client (``token_endpoint_auth_methods_supported: ["none"]``) and
``client_id_metadata_document_supported`` so clients present a URL-shaped
``client_id`` (CIMD) that HA core's long-standing IndieAuth handling accepts —
the user never pastes a credential. No ``registration_endpoint``: HA offers no
dynamic client registration; CIMD replaces it.
"""
return {
"issuer": f"{base}{OAUTH_BASE}",
"authorization_endpoint": f"{base}/auth/authorize",
"token_endpoint": f"{base}/auth/token",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"client_id_metadata_document_supported": True,
}
class ResourceServer:
"""ha_auth resource server: bearer validation + discovery URL building.
Owns no signing key, no client credentials, and binds no root views — HA core
is the authorization server. Held by the discovery views and the webhook
handler.
"""
def __init__(self, hass: HomeAssistant, webhook_id: str) -> None:
"""Bind to the HA instance and this install's webhook id."""
self._hass = hass
self._webhook_id = webhook_id
@property
def webhook_id(self) -> str:
"""This install's private webhook id."""
return self._webhook_id
def resource_url(self, base_url: str) -> str:
"""Absolute URL of the protected webhook resource under ``base_url``."""
return f"{base_url}/api/webhook/{self._webhook_id}"
def authorization_server_url(self, base_url: str) -> str:
"""Issuer / authorization-server URL under ``base_url``."""
return f"{base_url}{OAUTH_BASE}"
async def validate_request(self, request: web.Request) -> bool:
"""Return True iff the request carries a Bearer token HA core accepts.
A missing/malformed ``Authorization`` header is rejected without touching
the validator. ``hass.auth.async_validate_access_token`` is a synchronous
``@callback`` in HA core; it is awaited defensively in case a future
release makes it a coroutine, and any raise is treated as unauthorized so
a crafted token yields a 401 challenge rather than a 500.
"""
header = request.headers.get("Authorization", "")
if not header.lower().startswith("bearer "):
return False
token = header[7:].strip()
if not token:
return False
try:
result = self._hass.auth.async_validate_access_token(token)
if inspect.isawaitable(result):
result = await result
except Exception:
_LOGGER.debug(
"ha_auth: bearer validation raised; treating as unauthorized",
exc_info=True,
)
return False
if result is None:
return False
# ADMIN-ONLY: the server performs every Home Assistant operation with
# its own provisioned ADMIN token, so accepting any valid login would
# grant every household member admin-equivalent control. Require an
# active, human, administrator account (mirrors the settings panel).
user = getattr(result, "user", None)
if user is None:
return False
if getattr(user, "system_generated", False):
return False
if not getattr(user, "is_active", False):
return False
return bool(getattr(user, "is_admin", False))
# ---------------------------------------------------------------------------
# RFC 8414 / RFC 9728 discovery views (ha_auth mode only)
# ---------------------------------------------------------------------------
def _active_resource_server(hass: HomeAssistant) -> ResourceServer | None:
"""Return the CURRENT entry's ha_auth resource server, or None.
The discovery views resolve this per request instead of binding a provider
at registration time: aiohttp can't drop a bound view until HA restarts, so
a remove + re-add of the config entry (which mints a NEW webhook id in the
same HA session) would otherwise leave the views advertising the old id.
Returns None when no entry is live, the webhook auth mode is not ha_auth,
or the public endpoint is disabled (local-only mode constructs no resource
server even under ha_auth) — the views then 404 like an unregistered route.
"""
domain_data = hass.data.get(DOMAIN)
if not isinstance(domain_data, dict):
return None
cfg = domain_data.get(DATA_WEBHOOK)
if not isinstance(cfg, dict) or cfg.get("auth_mode") != WEBHOOK_AUTH_HA:
return None
provider = cfg.get("resource_server")
return provider if isinstance(provider, ResourceServer) else None
def _json_not_found() -> web.Response:
"""404 JSON body used by stale-but-bound discovery views."""
return web.json_response({"error": "not_found"}, status=404)
def _protected_resource_document(provider: ResourceServer, base: str) -> dict[str, Any]:
"""RFC 9728 protected-resource document for ``provider`` under ``base``."""
return {
"resource": provider.resource_url(base),
"authorization_servers": [provider.authorization_server_url(base)],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://github.com/homeassistant-ai/ha-mcp",
}
class _ProtectedResourceMetadataView(HomeAssistantView):
"""RFC 9728 Protected Resource Metadata."""
requires_auth = False
cors_allowed = True
url = f"{OAUTH_BASE}/protected-resource"
name = "ha_mcp_tools:oauth:protected-resource"
def __init__(self, hass: HomeAssistant) -> None:
"""Bind the view to the HA instance; the provider is resolved per request."""
self._hass = hass
async def get(self, request: web.Request) -> web.Response:
"""Serve the protected-resource document (or 404 when ha_auth is off)."""
provider = _active_resource_server(self._hass)
if provider is None:
return _json_not_found()
return web.json_response(
_protected_resource_document(provider, _build_base_url(request))
)
class _AuthorizationServerMetadataView(HomeAssistantView):
"""RFC 8414 Authorization Server Metadata (points at HA core's OAuth)."""
requires_auth = False
cors_allowed = True
url = f"{OAUTH_BASE}/authorization-server"
name = "ha_mcp_tools:oauth:authorization-server"
def __init__(self, hass: HomeAssistant) -> None:
"""Bind the view to the HA instance; liveness is resolved per request."""
self._hass = hass
async def get(self, request: web.Request) -> web.Response:
"""Serve the authorization-server document (or 404 when ha_auth is off)."""
if _active_resource_server(self._hass) is None:
return _json_not_found()
base = _build_base_url(request)
return web.json_response(_authorization_server_document(base))
class _WellKnownProtectedResourceView(HomeAssistantView):
"""RFC 9728 §3.1 path-scoped Protected Resource Metadata.
Same document as :class:`_ProtectedResourceMetadataView`, served at the
well-known location derived from the webhook resource URL — claude.ai's
first fallback probe when the 401's ``resource_metadata`` pointer is
missing. The webhook id is a ROUTE PARAMETER (not baked into the path at
registration): a remove + re-add of the entry mints a new webhook id in the
same HA session, and the bound view must serve whichever id is currently
live (404 for any other). Standalone view (not a subclass of the plain
document view) because its handler takes the extra route parameter.
"""
requires_auth = False
cors_allowed = True
name = "ha_mcp_tools:oauth:wellknown-protected-resource"
url = "/.well-known/oauth-protected-resource/api/webhook/{webhook_id}"
def __init__(self, hass: HomeAssistant) -> None:
"""Bind the view to the HA instance; the provider is resolved per request."""
self._hass = hass
async def get(self, request: web.Request, webhook_id: str) -> web.Response:
"""Serve the document only for the CURRENT entry's webhook id."""
provider = _active_resource_server(self._hass)
if provider is None or webhook_id != provider.webhook_id:
return _json_not_found()
return web.json_response(
_protected_resource_document(provider, _build_base_url(request))
)
class _WellKnownAuthorizationServerMetadataView(_AuthorizationServerMetadataView):
"""RFC 8414 / OIDC-discovery locations for the AS metadata document.
Same document as :class:`_AuthorizationServerMetadataView`, registered at the
well-known URLs MCP clients actually probe for the issuer.
"""
def __init__(self, hass: HomeAssistant, url: str, name: str) -> None:
"""Bind and set an explicit well-known URL + unique view name."""
super().__init__(hass)
self.url = url
self.name = name
def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
"""Build the seven ha_auth discovery-document views (provider-agnostic)."""
views: list[HomeAssistantView] = [
_ProtectedResourceMetadataView(hass),
_AuthorizationServerMetadataView(hass),
_WellKnownProtectedResourceView(hass),
]
for url, name in (
(
f"/.well-known/oauth-authorization-server{OAUTH_BASE}",
"ha_mcp_tools:oauth:wellknown-as-rfc8414",
),
(
f"/.well-known/openid-configuration{OAUTH_BASE}",
"ha_mcp_tools:oauth:wellknown-oidc-prefixed",
),
(
f"{OAUTH_BASE}/.well-known/openid-configuration",
"ha_mcp_tools:oauth:wellknown-oidc-suffixed",
),
(
f"{OAUTH_BASE}/.well-known/oauth-authorization-server",
"ha_mcp_tools:oauth:wellknown-as-suffixed",
),
):
views.append(
_WellKnownAuthorizationServerMetadataView(hass, url=url, name=name)
)
return views
def _register_metadata_views(hass: HomeAssistant) -> None:
"""Register the ha_auth discovery views at most once per HA session.
aiohttp cannot unregister a bound view, so a reload / re-enable / re-add must
reuse the already-bound views — they resolve the ACTIVE provider from
hass.data per request, so a later entry (even with a new webhook id) is
served correctly. The guard flag lives at a top-level hass.data key that
survives config-entry teardown.
"""
if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY):
return
for view in _metadata_views(hass):
hass.http.register_view(view)
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
def _build_unauthorized_response(
request: web.Request, provider: ResourceServer
) -> web.Response:
"""Build the 401 + ``WWW-Authenticate`` challenge MCP clients use to discover.
Per RFC 9728 §5.1 / MCP spec, the ``resource_metadata`` parameter points to
the protected-resource metadata URL where the client finds the authorization
server.
"""
base = _build_base_url(request)
metadata_url = f"{base}{OAUTH_BASE}/protected-resource"
return web.Response(
status=401,
text="Unauthorized",
headers={
"WWW-Authenticate": (
f'Bearer realm="HA-MCP", resource_metadata="{metadata_url}"'
)
},
)
# ---------------------------------------------------------------------------
# Webhook forwarding handler
# ---------------------------------------------------------------------------
async def _async_handle_webhook(
hass: HomeAssistant, webhook_id: str, request: web.Request
) -> web.StreamResponse:
"""Forward an MCP request to the loopback server and stream the reply back."""
domain_data = hass.data.get(DOMAIN)
cfg = domain_data.get(DATA_WEBHOOK) if isinstance(domain_data, dict) else None
if not isinstance(cfg, dict):
return web.Response(status=503, text="MCP server is not available")
# Auth gate. ``none`` = the secret webhook URL is the credential; ``ha_auth``
# = validate the bearer via HA core, and on failure emit the 401 discovery
# challenge so the client can start the OAuth flow. Gate on the PROVIDER
# (constructed only for ha_auth) rather than a string compare, so the
# coupling "provider present <=> ha_auth" has a single owner and an
# inconsistent cfg cannot fail open.
provider = cfg.get("resource_server")
if provider is not None:
if not await provider.validate_request(request):
return _build_unauthorized_response(request, provider)
target_url: str = cfg["target_url"]
session: aiohttp.ClientSession = cfg["session"]
body = await request.read()
forward_headers = {
key: value
for key, value in request.headers.items()
if key.lower() not in _STRIPPED_REQUEST_HEADERS
}
try:
async with session.request(
method=request.method,
url=target_url,
headers=forward_headers,
data=body if body else None,
) as upstream_resp:
content_type = upstream_resp.headers.get("Content-Type", "")
resp_headers = {
"Cache-Control": "no-cache, no-transform",
"Content-Encoding": "identity",
}
mcp_session = upstream_resp.headers.get("Mcp-Session-Id")
if mcp_session:
resp_headers["Mcp-Session-Id"] = mcp_session
if "text/event-stream" in content_type:
# SSE streaming: prevent HA's compression middleware from
# buffering/breaking the stream (supervisor#6470).
resp_headers["Content-Type"] = "text/event-stream"
resp_headers["X-Accel-Buffering"] = "no"
response = web.StreamResponse(
status=upstream_resp.status, headers=resp_headers
)
await response.prepare(request)
# Once prepare() has sent the 200 + headers, a mid-stream
# upstream failure can no longer become a 502 — returning a
# fresh Response here would be silently dropped and the client
# would see only a truncated stream with no log trail. End the
# prepared stream deterministically and log instead.
# Count forwarded bytes manually: StreamResponse.body_length
# is only assigned in write_eof(), so it is still 0 here.
bytes_forwarded = 0
try:
async for chunk in upstream_resp.content.iter_any():
await response.write(chunk)
bytes_forwarded += len(chunk)
except aiohttp.ClientError as err:
_LOGGER.error(
"MCP webhook: upstream dropped mid-stream after %d bytes: %s",
bytes_forwarded,
err,
)
with suppress(ConnectionResetError):
await response.write_eof()
return response
if not any(ct in content_type for ct in _ALLOWED_CONTENT_TYPES):
content_type = "application/json"
resp_headers["Content-Type"] = content_type
resp_body = await upstream_resp.read()
return web.Response(
status=upstream_resp.status, body=resp_body, headers=resp_headers
)
except aiohttp.ClientError as err:
_LOGGER.error("MCP webhook: upstream request failed: %s", err)
return web.Response(status=502, text="MCP server unavailable")
except Exception as err:
_LOGGER.exception("MCP webhook: unexpected error: %s", err)
return web.Response(status=500, text="MCP server internal error")
# ---------------------------------------------------------------------------
# Registration / teardown
# ---------------------------------------------------------------------------
async def async_register_webhook(
hass: HomeAssistant,
entry: ConfigEntry,
*,
port: int,
secret_path: str,
auth_mode: str,
register_endpoint: bool = True,
) -> None:
"""Register the ingress webhook (and, for ha_auth, the discovery views).
Stores the forwarding config in ``hass.data[DOMAIN][DATA_WEBHOOK]`` and opens
a long-lived aiohttp session for streaming. Raises on failure with the webhook
already unregistered, so the caller never leaves a half-configured endpoint
live. ``webhook`` is a manifest dependency, so HA guarantees it is set up
before this runs.
With ``register_endpoint=False`` (remote webhook access disabled by option)
no public endpoint or ha_auth surface is created — and any leftover endpoint
from a crashed unload is cleared, so off means off; only the forwarding
config is stored, which same-host consumers — the sidebar settings panel
proxy — need to reach the loopback server (#1803).
"""
if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA):
# Fail CLOSED on an unknown mode (corrupt/migrated options): refusing
# bring-up files a repair issue, instead of an unrecognized string
# silently taking the unauthenticated forward path.
raise ValueError(f"Unknown webhook auth mode: {auth_mode!r}")
webhook_id: str = entry.data[DATA_WEBHOOK_ID]
# Reload-safe and off-means-off: clear any leftover registration from a
# crashed unload before (re)registering — or before storing a local-only
# config (async_unregister is a no-op pop when nothing is registered).
# Runs before the session opens so a raise here cannot leak it.
async_unregister(hass, webhook_id)
target_url = f"http://127.0.0.1:{port}{secret_path}"
session = aiohttp.ClientSession(timeout=_CLIENT_TIMEOUT)
cfg: dict[str, Any] = {
"webhook_id": webhook_id,
"target_url": target_url,
"session": session,
"auth_mode": auth_mode,
"resource_server": None,
}
if register_endpoint:
try:
async_register(
hass,
DOMAIN,
_WEBHOOK_NAME,
webhook_id,
_async_handle_webhook,
allowed_methods=["POST", "GET"],
)
if auth_mode == WEBHOOK_AUTH_HA:
provider = ResourceServer(hass, webhook_id)
_register_metadata_views(hass)
cfg["resource_server"] = provider
except Exception:
# Never leave a live endpoint (or a leaked session) behind a failed
# auth-setup path. suppress: the ORIGINAL error must be what
# propagates (review finding) - a raising cleanup would mask it.
with suppress(Exception):
async_unregister(hass, webhook_id)
with suppress(Exception):
await session.close()
raise
hass.data.setdefault(DOMAIN, {})[DATA_WEBHOOK] = cfg
async def async_unregister_webhook(hass: HomeAssistant) -> None:
"""Unregister the ingress webhook and close its aiohttp session.
Idempotent. The ha_auth discovery views are intentionally left bound (aiohttp
can't unregister them until HA restarts); they 404 while ha_auth is not live.
"""
domain_data = hass.data.get(DOMAIN)
if not isinstance(domain_data, dict):
return
cfg = domain_data.pop(DATA_WEBHOOK, None)
if not isinstance(cfg, dict):
return
webhook_id = cfg.get("webhook_id")
if webhook_id:
async_unregister(hass, webhook_id)
session = cfg.get("session")
if session is not None:
await session.close()