Skip to content

Commit 549c6ce

Browse files
fix: none-mode webhook connects from claude.ai with no login (#1976)
* fix: none-mode webhook connects from claude.ai with no login In none (OAuth-off) webhook mode, claude.ai's connector onboarding intermittently front-loads OAuth discovery and falls through to Home Assistant core's origin-root /.well-known/oauth-authorization-server (which we cannot remove or override), which advertises CIMD but omits token_endpoint_auth_methods_supported: ["none"] and a registration endpoint -> "Automatic client registration isn't supported... add an OAuth Client ID". In none mode, serve our own corrected path-scoped RFC 8414/9728 discovery plus an invisible auto-approve authorization server (PKCE S256, allowlist-only redirect guard) so discovery resolves against us and completes with no HA login. The webhook stays 200 with no bearer required, so URL-only clients are unaffected; the none<->ha_auth switch needs no restart. Mirrored into the webhook-proxy dev add-on (2.0.3.dev2 -> 2.0.3.dev3); stable add-on untouched. Fixes #1969 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9Akiw2qg7qh6QWiZjqBoX * fix: don't leak the webhook id via the fixed-path protected-resource in none mode Address Patch76's review on #1976: - In none mode the webhook id is the sole credential, but the fixed-path {OAUTH_BASE}/protected-resource view (anonymous, guessable) carried it in its `resource` field. Serve that view only for the bearer-gated ha_auth / legacy modes; the path-scoped well-known view still serves in none mode (its id is a route parameter the caller must already know). Mirrored to the dev add-on. - Add Cache-Control: no-store to the dev add-on's auto-approve token response for parity with the component. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9Akiw2qg7qh6QWiZjqBoX * refactor: move _TOKEN_RESPONSE_HEADERS to addon oauth_autoapprove The add-on's legacy token views don't set no-store, so the constant was unused in oauth.py and flagged by CodeQL Code Quality (py/unused-global- variable, which counts only module-local usage). Move it into oauth_autoapprove.py — its sole consumer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9Akiw2qg7qh6QWiZjqBoX --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e658112 commit 549c6ce

12 files changed

Lines changed: 2282 additions & 151 deletions

File tree

custom_components/ha_mcp_tools/mcp_webhook.py

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
Three auth postures, chosen in the options flow:
99
1010
* ``none`` — the secret webhook URL *is* the credential (matches the add-on's
11-
default). No bearer is required.
11+
default). No bearer is required and the forwarder always returns 200. It still
12+
serves our own corrected RFC 8414 / RFC 9728 discovery documents plus an
13+
invisible auto-approve authorization server (:mod:`oauth_autoapprove`), so
14+
claude.ai's intermittent OAuth discovery resolves against us — not HA core's
15+
broken origin-root doc — and connects with no HA login (issue #1969).
1216
* ``ha_auth`` — Home Assistant core is the OAuth authorization server. This
1317
module serves the RFC 8414 / RFC 9728 discovery documents (so claude.ai /
1418
ChatGPT can sign in with the user's HA account) and validates inbound bearer
@@ -26,7 +30,9 @@
2630
provider + its root ``/authorize`` + ``/token`` views live in
2731
:mod:`oauth_legacy`, ported from the ``legacy`` subset of the add-on's
2832
``oauth.py``. The seven RFC 8414 / RFC 9728 discovery views below are shared by
29-
``ha_auth`` and ``legacy`` — see :func:`active_auth_mode`.
33+
``ha_auth``, ``legacy``, and ``none`` (which serves a distinct auto-approve
34+
authorization-server document pointing at :mod:`oauth_autoapprove`'s endpoints)
35+
— see :func:`active_auth_mode`.
3036
"""
3137

3238
from __future__ import annotations
@@ -51,6 +57,11 @@
5157
WEBHOOK_AUTH_LEGACY,
5258
WEBHOOK_AUTH_NONE,
5359
)
60+
from .oauth_autoapprove import (
61+
CFG_AUTOAPPROVE_PROVIDER,
62+
AutoApproveProvider,
63+
bind_autoapprove_views,
64+
)
5465
from .oauth_legacy import (
5566
AUTHORIZE_PATH,
5667
OAUTH_ROUTE_OWNER_KEY,
@@ -219,14 +230,14 @@ def _active_webhook_cfg(hass: HomeAssistant) -> dict[str, Any] | None:
219230
def active_auth_mode(hass: HomeAssistant) -> str | None:
220231
"""Return the OAuth-relevant auth mode of the live webhook registration.
221232
222-
``WEBHOOK_AUTH_HA`` or ``WEBHOOK_AUTH_LEGACY``, or None when no OAuth
233+
``WEBHOOK_AUTH_HA``, ``WEBHOOK_AUTH_LEGACY``, or ``WEBHOOK_AUTH_NONE`` (the
234+
none-mode auto-approve surface, issue #1969), or None when no discovery
223235
surface is live. Checked via PROVIDER PRESENCE, not the raw configured
224236
``auth_mode`` string, so local-only mode (remote webhook disabled by
225237
option — ``register_endpoint=False`` in ``async_register_webhook``)
226-
correctly reports None even when ``webhook_auth`` is set to
227-
``ha_auth``/``legacy``: no provider is constructed for a webhook that was
228-
never registered, so there is nothing to advertise or authenticate
229-
against. Read live from hass.data (not captured at view/provider
238+
correctly reports None even when ``webhook_auth`` is set: no provider is
239+
constructed for a webhook that was never registered, so there is nothing to
240+
advertise or authenticate against. Read live from hass.data (not captured at view/provider
230241
construction time) so the SAME registered/bound instances serve whichever
231242
mode is active now — mirrors the add-on's ``_active_oauth_mode``. Used by
232243
the discovery views below AND by ``LegacyOAuthProvider.is_active`` (via
@@ -246,6 +257,8 @@ def active_auth_mode(hass: HomeAssistant) -> str | None:
246257
return WEBHOOK_AUTH_HA
247258
if cfg.get("oauth_provider") is not None:
248259
return WEBHOOK_AUTH_LEGACY
260+
if cfg.get(CFG_AUTOAPPROVE_PROVIDER) is not None:
261+
return WEBHOOK_AUTH_NONE
249262
return None
250263

251264

@@ -295,6 +308,32 @@ def _legacy_authorization_server_document(base: str) -> dict[str, Any]:
295308
}
296309

297310

311+
def _none_mode_authorization_server_document(base: str) -> dict[str, Any]:
312+
"""RFC 8414 authorization-server metadata for none mode's auto-approve server.
313+
314+
Points at OUR OWN ``OAUTH_BASE`` ``/authorize`` + ``/token`` (the invisible
315+
auto-approve endpoints in :mod:`oauth_autoapprove`), NOT HA core's
316+
``/auth/*``. Serving this — with ``token_endpoint_auth_methods_supported:
317+
["none"]`` (public PKCE client) and ``client_id_metadata_document_supported``
318+
— is the none-mode fix: claude.ai's intermittent discovery resolves against
319+
this corrected document instead of HA core's origin-root
320+
``/.well-known/oauth-authorization-server``, which omits the ``"none"`` auth
321+
method and has no ``registration_endpoint`` (issue #1969). No refresh grant:
322+
the token is cosmetic (none mode ignores bearers), so only
323+
``authorization_code`` is advertised.
324+
"""
325+
return {
326+
"issuer": f"{base}{OAUTH_BASE}",
327+
"authorization_endpoint": f"{base}{OAUTH_BASE}/authorize",
328+
"token_endpoint": f"{base}{OAUTH_BASE}/token",
329+
"response_types_supported": ["code"],
330+
"grant_types_supported": ["authorization_code"],
331+
"code_challenge_methods_supported": ["S256"],
332+
"token_endpoint_auth_methods_supported": ["none"],
333+
"client_id_metadata_document_supported": True,
334+
}
335+
336+
298337
class _ProtectedResourceMetadataView(HomeAssistantView):
299338
"""RFC 9728 Protected Resource Metadata."""
300339

@@ -308,7 +347,18 @@ def __init__(self, hass: HomeAssistant) -> None:
308347
self._hass = hass
309348

310349
async def get(self, request: web.Request) -> web.Response:
311-
"""Serve the protected-resource document (or 404 when no OAuth mode is live)."""
350+
"""Serve the protected-resource document for the bearer-gated modes only.
351+
352+
SECURITY (#1976 review): this ANONYMOUS, fixed (guessable) path exposes
353+
``resource: <base>/api/webhook/<id>``. In none mode the webhook id is the
354+
SOLE credential, so serving it here would leak it to any unauthenticated
355+
GET. Serve only for ``ha_auth``/``legacy`` (where the id is not a secret
356+
and the 401 ``WWW-Authenticate`` pointer legitimately directs a client
357+
here); 404 otherwise. The PATH-SCOPED well-known view still serves in none
358+
mode — its caller must already know the id (it is a route parameter).
359+
"""
360+
if active_auth_mode(self._hass) not in (WEBHOOK_AUTH_HA, WEBHOOK_AUTH_LEGACY):
361+
return _json_not_found()
312362
webhook_id = _active_webhook_id(self._hass)
313363
if webhook_id is None:
314364
return _json_not_found()
@@ -341,6 +391,8 @@ async def get(self, request: web.Request) -> web.Response:
341391
base = _build_base_url(request)
342392
if mode == WEBHOOK_AUTH_LEGACY:
343393
return web.json_response(_legacy_authorization_server_document(base))
394+
if mode == WEBHOOK_AUTH_NONE:
395+
return web.json_response(_none_mode_authorization_server_document(base))
344396
return web.json_response(_authorization_server_document(base))
345397

346398

@@ -636,6 +688,7 @@ async def async_register_webhook(
636688
"auth_mode": auth_mode,
637689
"resource_server": None,
638690
"oauth_provider": None,
691+
CFG_AUTOAPPROVE_PROVIDER: None,
639692
}
640693

641694
oauth_restart_needed = False
@@ -672,6 +725,19 @@ async def async_register_webhook(
672725
"enable legacy mode again."
673726
) from err
674727
cfg["oauth_provider"] = oauth_provider
728+
else:
729+
# WEBHOOK_AUTH_NONE (the only remaining mode — unknown modes
730+
# already raised above). The secret webhook URL is the
731+
# credential, but we still serve our own corrected discovery +
732+
# an invisible auto-approve authorization server so claude.ai's
733+
# intermittent OAuth discovery resolves against us instead of HA
734+
# core's broken origin-root document, and completes with no HA
735+
# login (issue #1969). Both view bundles bind at most once per
736+
# HA session; the per-request resolvers gate them on this cfg,
737+
# so a none<->ha_auth switch needs no restart.
738+
_register_metadata_views(hass)
739+
bind_autoapprove_views(hass)
740+
cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider()
675741
except Exception:
676742
# Never leave a live endpoint (or a leaked session) behind a failed
677743
# auth-setup path. suppress: the ORIGINAL error must be what

0 commit comments

Comments
 (0)