Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
+267 -72
View File
@@ -5,21 +5,34 @@ Ported from the proven webhook-proxy add-on (``mcp_proxy``): an HA webhook
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:
Three 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.
default). No bearer is required and the forwarder always returns 200. It still
serves our own corrected RFC 8414 / RFC 9728 discovery documents plus an
invisible auto-approve authorization server (:mod:`oauth_autoapprove`), so
claude.ai's intermittent OAuth discovery resolves against us — not HA core's
broken origin-root doc — and connects with no HA login (issue #1969).
* ``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/*``.
* ``legacy`` — this module (via :mod:`oauth_legacy`) is its own OAuth 2.1
authorization server with a static client_id/secret, for MCP clients (Google
Gemini Spark) that need a credential to paste rather than an HA sign-in.
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``.
``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``; the ``legacy``
provider + its root ``/authorize`` + ``/token`` views live in
:mod:`oauth_legacy`, ported from the ``legacy`` subset of the add-on's
``oauth.py``. The seven RFC 8414 / RFC 9728 discovery views below are shared by
``ha_auth``, ``legacy``, and ``none`` (which serves a distinct auto-approve
authorization-server document pointing at :mod:`oauth_autoapprove`'s endpoints)
— see :func:`active_auth_mode`.
"""
from __future__ import annotations
@@ -41,8 +54,22 @@ from .const import (
DOMAIN,
OAUTH_BASE,
WEBHOOK_AUTH_HA,
WEBHOOK_AUTH_LEGACY,
WEBHOOK_AUTH_NONE,
)
from .oauth_autoapprove import (
CFG_AUTOAPPROVE_PROVIDER,
AutoApproveProvider,
bind_autoapprove_views,
)
from .oauth_legacy import (
AUTHORIZE_PATH,
OAUTH_ROUTE_OWNER_KEY,
TOKEN_PATH,
LegacyOAuthProvider,
LegacyOAuthRouteConflict,
bind_legacy_views,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
@@ -145,14 +172,6 @@ class ResourceServer:
"""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.
@@ -195,29 +214,62 @@ class ResourceServer:
# ---------------------------------------------------------------------------
# RFC 8414 / RFC 9728 discovery views (ha_auth mode only)
# RFC 8414 / RFC 9728 discovery views (ha_auth + legacy modes)
# ---------------------------------------------------------------------------
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.
"""
def _active_webhook_cfg(hass: HomeAssistant) -> dict[str, Any] | None:
"""Return the live webhook forwarding cfg dict, or None if not set up."""
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 cfg if isinstance(cfg, dict) else None
def active_auth_mode(hass: HomeAssistant) -> str | None:
"""Return the OAuth-relevant auth mode of the live webhook registration.
``WEBHOOK_AUTH_HA``, ``WEBHOOK_AUTH_LEGACY``, or ``WEBHOOK_AUTH_NONE`` (the
none-mode auto-approve surface, issue #1969), or None when no discovery
surface is live. Checked via PROVIDER PRESENCE, not the raw configured
``auth_mode`` string, so local-only mode (remote webhook disabled by
option — ``register_endpoint=False`` in ``async_register_webhook``)
correctly reports None even when ``webhook_auth`` is set: no provider is
constructed for a webhook that was never registered, so there is nothing to
advertise or authenticate against. Read live from hass.data (not captured at view/provider
construction time) so the SAME registered/bound instances serve whichever
mode is active now — mirrors the add-on's ``_active_oauth_mode``. Used by
the discovery views below AND by ``LegacyOAuthProvider.is_active`` (via
the getter passed into :func:`oauth_legacy.bind_legacy_views`) so the
root ``/authorize``/``/token`` views, which aiohttp can never unbind, 404
once the operator switches away from legacy (or to local-only mode)
without a restart. The webhook forwarder's own bearer gate
(``_async_handle_webhook``) reads ``cfg["resource_server"]`` /
``cfg["oauth_provider"]`` directly instead of through this function — it
already has ``cfg`` in hand and needs the provider OBJECT, not just the
mode name.
"""
cfg = _active_webhook_cfg(hass)
if cfg is None:
return None
provider = cfg.get("resource_server")
return provider if isinstance(provider, ResourceServer) else None
if cfg.get("resource_server") is not None:
return WEBHOOK_AUTH_HA
if cfg.get("oauth_provider") is not None:
return WEBHOOK_AUTH_LEGACY
if cfg.get(CFG_AUTOAPPROVE_PROVIDER) is not None:
return WEBHOOK_AUTH_NONE
return None
def _active_webhook_id(hass: HomeAssistant) -> str | None:
"""Webhook id of the live registration, gated the same as the AS document
(None whenever :func:`active_auth_mode` is None) so the protected-resource
document 404s in exactly the same cases."""
if active_auth_mode(hass) is None:
return None
cfg = _active_webhook_cfg(hass)
return cfg.get("webhook_id") if cfg is not None else None
def _json_not_found() -> web.Response:
@@ -225,16 +277,63 @@ def _json_not_found() -> web.Response:
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``."""
def _protected_resource_document(webhook_id: str, base: str) -> dict[str, Any]:
"""RFC 9728 protected-resource document for ``webhook_id`` under ``base``.
Identical shape in both OAuth modes — only the authorization-server
document (below) differs by mode.
"""
return {
"resource": provider.resource_url(base),
"authorization_servers": [provider.authorization_server_url(base)],
"resource": f"{base}/api/webhook/{webhook_id}",
"authorization_servers": [f"{base}{OAUTH_BASE}"],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://github.com/homeassistant-ai/ha-mcp",
}
def _legacy_authorization_server_document(base: str) -> dict[str, Any]:
"""RFC 8414 authorization-server metadata for legacy mode's own root
``/authorize`` + ``/token`` views (see :mod:`oauth_legacy`)."""
return {
"issuer": f"{base}{OAUTH_BASE}",
"authorization_endpoint": f"{base}{AUTHORIZE_PATH}",
"token_endpoint": f"{base}{TOKEN_PATH}",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
],
}
def _none_mode_authorization_server_document(base: str) -> dict[str, Any]:
"""RFC 8414 authorization-server metadata for none mode's auto-approve server.
Points at OUR OWN ``OAUTH_BASE`` ``/authorize`` + ``/token`` (the invisible
auto-approve endpoints in :mod:`oauth_autoapprove`), NOT HA core's
``/auth/*``. Serving this — with ``token_endpoint_auth_methods_supported:
["none"]`` (public PKCE client) and ``client_id_metadata_document_supported``
— is the none-mode fix: claude.ai's intermittent discovery resolves against
this corrected document instead of HA core's origin-root
``/.well-known/oauth-authorization-server``, which omits the ``"none"`` auth
method and has no ``registration_endpoint`` (issue #1969). No refresh grant:
the token is cosmetic (none mode ignores bearers), so only
``authorization_code`` is advertised.
"""
return {
"issuer": f"{base}{OAUTH_BASE}",
"authorization_endpoint": f"{base}{OAUTH_BASE}/authorize",
"token_endpoint": f"{base}{OAUTH_BASE}/token",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"client_id_metadata_document_supported": True,
}
class _ProtectedResourceMetadataView(HomeAssistantView):
"""RFC 9728 Protected Resource Metadata."""
@@ -244,21 +343,36 @@ class _ProtectedResourceMetadataView(HomeAssistantView):
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."""
"""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 protected-resource document (or 404 when ha_auth is off)."""
provider = _active_resource_server(self._hass)
if provider is None:
"""Serve the protected-resource document for the bearer-gated modes only.
SECURITY (#1976 review): this ANONYMOUS, fixed (guessable) path exposes
``resource: <base>/api/webhook/<id>``. In none mode the webhook id is the
SOLE credential, so serving it here would leak it to any unauthenticated
GET. Serve only for ``ha_auth``/``legacy`` (where the id is not a secret
and the 401 ``WWW-Authenticate`` pointer legitimately directs a client
here); 404 otherwise. The PATH-SCOPED well-known view still serves in none
mode — its caller must already know the id (it is a route parameter).
"""
if active_auth_mode(self._hass) not in (WEBHOOK_AUTH_HA, WEBHOOK_AUTH_LEGACY):
return _json_not_found()
webhook_id = _active_webhook_id(self._hass)
if webhook_id is None:
return _json_not_found()
return web.json_response(
_protected_resource_document(provider, _build_base_url(request))
_protected_resource_document(webhook_id, _build_base_url(request))
)
class _AuthorizationServerMetadataView(HomeAssistantView):
"""RFC 8414 Authorization Server Metadata (points at HA core's OAuth)."""
"""RFC 8414 Authorization Server Metadata.
Mode-aware: ha_auth points at HA core's own ``/auth/*``; legacy points at
this module's root ``/authorize``/``/token`` views.
"""
requires_auth = False
cors_allowed = True
@@ -270,10 +384,15 @@ class _AuthorizationServerMetadataView(HomeAssistantView):
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:
"""Serve the AS document (or 404 when no OAuth mode is live)."""
mode = active_auth_mode(self._hass)
if mode is None:
return _json_not_found()
base = _build_base_url(request)
if mode == WEBHOOK_AUTH_LEGACY:
return web.json_response(_legacy_authorization_server_document(base))
if mode == WEBHOOK_AUTH_NONE:
return web.json_response(_none_mode_authorization_server_document(base))
return web.json_response(_authorization_server_document(base))
@@ -296,16 +415,16 @@ class _WellKnownProtectedResourceView(HomeAssistantView):
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."""
"""Bind the view to the HA instance; liveness 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:
active_id = _active_webhook_id(self._hass)
if active_id is None or webhook_id != active_id:
return _json_not_found()
return web.json_response(
_protected_resource_document(provider, _build_base_url(request))
_protected_resource_document(active_id, _build_base_url(request))
)
@@ -324,7 +443,8 @@ class _WellKnownAuthorizationServerMetadataView(_AuthorizationServerMetadataView
def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
"""Build the seven ha_auth discovery-document views (provider-agnostic)."""
"""Build the seven discovery-document views, shared by ha_auth and legacy
(mode-agnostic — each view resolves the active mode per request)."""
views: list[HomeAssistantView] = [
_ProtectedResourceMetadataView(hass),
_AuthorizationServerMetadataView(hass),
@@ -355,13 +475,14 @@ def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
def _register_metadata_views(hass: HomeAssistant) -> None:
"""Register the ha_auth discovery views at most once per HA session.
"""Register the seven 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.
aiohttp cannot unregister a bound view, so a reload / re-enable / re-add /
ha_auth<->legacy mode switch must all reuse the already-bound views — they
resolve the ACTIVE mode + provider from hass.data per request (see
``active_auth_mode``), so a later entry (even with a new webhook id, or a
different auth mode) 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
@@ -370,9 +491,7 @@ def _register_metadata_views(hass: HomeAssistant) -> None:
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
def _build_unauthorized_response(
request: web.Request, provider: ResourceServer
) -> web.Response:
def _build_unauthorized_response(request: web.Request) -> 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
@@ -397,6 +516,29 @@ def _build_unauthorized_response(
# ---------------------------------------------------------------------------
async def _check_webhook_auth(
request: web.Request, cfg: dict[str, Any]
) -> web.StreamResponse | None:
"""Return a 401 challenge response if the request fails the auth gate, else None."""
# Auth gate. ``none`` = the secret webhook URL is the credential; ``ha_auth``
# validates the bearer via HA core; ``legacy`` validates it against this
# module's own opaque tokens. Either failure emits the same 401 discovery
# challenge so the client can start the OAuth flow. Gate on the PROVIDER
# (constructed only for the matching mode) rather than a string compare,
# so the coupling "provider present <=> mode" has a single owner and an
# inconsistent cfg cannot fail open. auth_mode makes the two mutually
# exclusive, so at most one of these is ever set.
resource_server: ResourceServer | None = cfg.get("resource_server")
if resource_server is not None and not await resource_server.validate_request(
request
):
return _build_unauthorized_response(request)
oauth_provider: LegacyOAuthProvider | None = cfg.get("oauth_provider")
if oauth_provider is not None and not oauth_provider.validate_bearer(request):
return _build_unauthorized_response(request)
return None
async def _async_handle_webhook(
hass: HomeAssistant, webhook_id: str, request: web.Request
) -> web.StreamResponse:
@@ -406,16 +548,9 @@ async def _async_handle_webhook(
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)
auth_response = await _check_webhook_auth(request, cfg)
if auth_response is not None:
return auth_response
target_url: str = cfg["target_url"]
session: aiohttp.ClientSession = cfg["session"]
@@ -504,22 +639,34 @@ async def async_register_webhook(
secret_path: str,
auth_mode: str,
register_endpoint: bool = True,
) -> None:
"""Register the ingress webhook (and, for ha_auth, the discovery views).
oauth_client_id: str | None = None,
oauth_client_secret: str | None = None,
oauth_signing_key: str | None = None,
) -> bool:
"""Register the ingress webhook (and, for ha_auth/legacy, the OAuth surface).
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.
before this runs. ``oauth_client_id``/``oauth_client_secret``/
``oauth_signing_key`` are required when ``auth_mode == WEBHOOK_AUTH_LEGACY``
(ignored otherwise); ``oauth_signing_key`` is the hex string persisted in
``entry.data`` — see ``oauth_legacy._normalize_signing_key``.
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).
no public endpoint or ha_auth/legacy 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).
Returns True when the caller should surface ``ISSUE_LEGACY_OAUTH_RESTART``:
the root ``/authorize``/``/token`` views just bound for the first time this
HA session (or with changed credentials), or they are still bound from a
prior legacy registration that this call has moved away from — either way
aiohttp cannot bind or release a view without a full HA restart.
"""
if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA):
if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA, WEBHOOK_AUTH_LEGACY):
# 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.
@@ -540,8 +687,11 @@ async def async_register_webhook(
"session": session,
"auth_mode": auth_mode,
"resource_server": None,
"oauth_provider": None,
CFG_AUTOAPPROVE_PROVIDER: None,
}
oauth_restart_needed = False
if register_endpoint:
try:
async_register(
@@ -556,6 +706,38 @@ async def async_register_webhook(
provider = ResourceServer(hass, webhook_id)
_register_metadata_views(hass)
cfg["resource_server"] = provider
elif auth_mode == WEBHOOK_AUTH_LEGACY:
if not (oauth_client_id and oauth_client_secret and oauth_signing_key):
raise ValueError(
"legacy webhook auth mode requires oauth_client_id, "
"oauth_client_secret, and oauth_signing_key"
)
_register_metadata_views(hass)
try:
oauth_provider, oauth_restart_needed = bind_legacy_views(
hass, oauth_client_id, oauth_client_secret, oauth_signing_key
)
except LegacyOAuthRouteConflict as err:
raise ValueError(
"The Webhook Proxy add-on (or its dev flavor) already "
f"owns the root /authorize and /token routes ({err}). "
"Stop that add-on and restart Home Assistant, then "
"enable legacy mode again."
) from err
cfg["oauth_provider"] = oauth_provider
else:
# WEBHOOK_AUTH_NONE (the only remaining mode — unknown modes
# already raised above). The secret webhook URL is the
# credential, but we still serve our own corrected discovery +
# an invisible auto-approve authorization server so claude.ai's
# intermittent OAuth discovery resolves against us instead of HA
# core's broken origin-root document, and completes with no HA
# login (issue #1969). Both view bundles bind at most once per
# HA session; the per-request resolvers gate them on this cfg,
# so a none<->ha_auth switch needs no restart.
_register_metadata_views(hass)
bind_autoapprove_views(hass)
cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider()
except Exception:
# Never leave a live endpoint (or a leaked session) behind a failed
# auth-setup path. suppress: the ORIGINAL error must be what
@@ -566,14 +748,27 @@ async def async_register_webhook(
await session.close()
raise
# A PRIOR registration this HA session may still own the legacy root views
# even though THIS call bound no legacy provider — either the mode is no
# longer legacy, OR legacy is still selected but the webhook endpoint is now
# off (register_endpoint=False skips the bind block above). aiohttp can never
# release a bound view without a restart, so gate on "no provider bound this
# call" (not the mode string) to surface the restart that releases route
# ownership in both cases.
if cfg["oauth_provider"] is None and hass.data.get(OAUTH_ROUTE_OWNER_KEY) == DOMAIN:
oauth_restart_needed = True
hass.data.setdefault(DOMAIN, {})[DATA_WEBHOOK] = cfg
return oauth_restart_needed
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.
Idempotent. The discovery views and the legacy root ``/authorize``/``/token``
views are intentionally left bound (aiohttp can't unregister them until HA
restarts); they 404 while their mode is not live (see ``active_auth_mode``
/ ``LegacyOAuthProvider.is_active``).
"""
domain_data = hass.data.get(DOMAIN)
if not isinstance(domain_data, dict):