Updated apps
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"""None-mode auto-approve OAuth authorization server (issue #1969).
|
||||
|
||||
In ``none`` webhook auth mode the secret webhook URL *is* the credential, so no
|
||||
bearer is required and the forwarder always returns 200. But claude.ai's
|
||||
connector onboarding intermittently front-loads OAuth discovery, and because the
|
||||
component registers no ``/.well-known`` views in none mode, claude.ai falls
|
||||
through to Home Assistant *core*'s own origin-root
|
||||
``/.well-known/oauth-authorization-server`` — which advertises
|
||||
``client_id_metadata_document_supported`` but omits
|
||||
``token_endpoint_auth_methods_supported: ["none"]`` and has no
|
||||
``registration_endpoint``. claude.ai then can neither use CIMD nor do dynamic
|
||||
client registration and shows "Automatic client registration isn't supported…".
|
||||
|
||||
This module is the none-mode fix's authorization-server half: a pair of
|
||||
path-scoped ``OAUTH_BASE`` endpoints that complete OAuth *invisibly* — no login,
|
||||
no consent — so a connector that does run discovery resolves against our own
|
||||
corrected documents (served by :mod:`mcp_webhook`) instead of HA core's broken
|
||||
root doc, and connects with zero HA login:
|
||||
|
||||
* ``GET {OAUTH_BASE}/authorize`` issues a PKCE-bound one-time code and
|
||||
immediately 302-redirects back to the client with ``?code=…&state=…`` — no
|
||||
page is rendered.
|
||||
* ``POST {OAUTH_BASE}/token`` exchanges that code (public client, PKCE S256, no
|
||||
``client_secret``) for an opaque access token. The token is *cosmetic* — none
|
||||
mode ignores bearers entirely — but is a real random string so a spec-strict
|
||||
client is satisfied.
|
||||
|
||||
Both views are gated per request off ``hass.data`` (they 404 unless none mode is
|
||||
the live webhook auth mode), mirroring the discovery views, so a
|
||||
``none``\\ ↔\\ ``ha_auth`` switch needs no restart. The PKCE code store and the
|
||||
redirect-URI floor are reused from :mod:`oauth_legacy` rather than copied.
|
||||
|
||||
**Open-redirect defence.** ``/authorize`` 302-redirects to a caller-supplied
|
||||
``redirect_uri`` on the Home Assistant origin, so an unvalidated target would be
|
||||
an open redirector. On top of :func:`oauth_legacy._is_valid_redirect_uri`'s
|
||||
scheme/host/port floor, the redirect must EXACTLY match a known MCP callback
|
||||
(:data:`_AUTOAPPROVE_REDIRECT_ALLOWLIST`). Anything else is a hard 400 (no
|
||||
redirect). A "same origin as the client_id" rule was deliberately NOT used: the
|
||||
client_id is fully attacker-controlled, so ``client_id == redirect_uri origin``
|
||||
still lets an attacker bounce a victim to any site of their choosing (a real
|
||||
open redirect on a public HA origin). Properly honouring an arbitrary CIMD
|
||||
client would require fetching the attacker-supplied client_id URL — an SSRF
|
||||
vector — so the allowlist is both the safe and the simple choice. Add a client's
|
||||
callback here to support it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
from .const import DATA_WEBHOOK, DOMAIN, OAUTH_BASE
|
||||
from .oauth_legacy import (
|
||||
_PKCE_CHALLENGE_RE,
|
||||
_TOKEN_RESPONSE_HEADERS,
|
||||
ACCESS_TOKEN_TTL,
|
||||
PKCECodeStore,
|
||||
_is_valid_redirect_uri,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
# cfg (hass.data[DOMAIN][DATA_WEBHOOK]) key holding the live AutoApproveProvider.
|
||||
# Present ONLY in none mode with the remote endpoint enabled; its presence is
|
||||
# how :func:`mcp_webhook.active_auth_mode` recognises the none-autoapprove live
|
||||
# mode (mirrors the "resource_server"/"oauth_provider" presence keys).
|
||||
CFG_AUTOAPPROVE_PROVIDER = "autoapprove_provider"
|
||||
|
||||
# TOP-LEVEL hass.data flag recording that the two auto-approve views are bound
|
||||
# for this HA session. Not under DOMAIN so it survives async_unload_entry's
|
||||
# teardown — aiohttp cannot unregister a bound view until HA restarts, so the
|
||||
# views (and this ownership flag) must outlive the config entry (mirrors
|
||||
# mcp_webhook._OAUTH_VIEWS_REGISTERED_KEY).
|
||||
_AUTOAPPROVE_VIEWS_REGISTERED_KEY = "ha_mcp_tools_oauth_autoapprove_views_registered"
|
||||
|
||||
# Known MCP OAuth callback URLs always accepted as a redirect target even when
|
||||
# the client_id is not a same-origin URL — claude.ai's connector onboarding
|
||||
# posts its authorization code here. Exact-match only (never a prefix test, so
|
||||
# ``https://claude.ai/api/mcp/auth_callback.evil.example`` cannot slip through).
|
||||
_AUTOAPPROVE_REDIRECT_ALLOWLIST = frozenset(
|
||||
{
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _json_not_found() -> web.Response:
|
||||
"""404 JSON body used when none-autoapprove is not the live mode."""
|
||||
return web.json_response({"error": "not_found"}, status=404)
|
||||
|
||||
|
||||
def _json_error(
|
||||
error: str, status: int, description: str | None = None
|
||||
) -> web.Response:
|
||||
"""OAuth-style JSON error (RFC 6749 §5.2 shape) with no-store headers."""
|
||||
body: dict[str, str] = {"error": error}
|
||||
if description is not None:
|
||||
body["error_description"] = description
|
||||
return web.json_response(body, status=status, headers=_TOKEN_RESPONSE_HEADERS)
|
||||
|
||||
|
||||
def _is_valid_autoapprove_redirect(redirect_uri: str) -> bool:
|
||||
"""Open-redirect gate for the auto-approve ``/authorize`` view.
|
||||
|
||||
Exact-match allowlist only, on top of
|
||||
:func:`oauth_legacy._is_valid_redirect_uri`'s scheme/host/port floor. The
|
||||
``client_id`` is NOT consulted: it is attacker-controlled, so validating the
|
||||
redirect against it (even "same origin") does not constrain the redirect
|
||||
target to a trusted host. See the module docstring.
|
||||
"""
|
||||
return (
|
||||
_is_valid_redirect_uri(redirect_uri)
|
||||
and redirect_uri in _AUTOAPPROVE_REDIRECT_ALLOWLIST
|
||||
)
|
||||
|
||||
|
||||
def _redirect_with(redirect_uri: str, **params: str) -> web.Response:
|
||||
"""302 to ``redirect_uri`` with ``params`` merged into its query string."""
|
||||
# yarl ships with aiohttp and handles existing-query merging + encoding
|
||||
# correctly — safer than hand-rolling (matches oauth_legacy.AuthorizeView).
|
||||
import yarl
|
||||
|
||||
url = yarl.URL(redirect_uri).update_query(params)
|
||||
return web.Response(status=302, headers={"Location": str(url)})
|
||||
|
||||
|
||||
class AutoApproveProvider:
|
||||
"""None-mode auto-approve authorization-server state.
|
||||
|
||||
Holds only the PKCE code store shared with :mod:`oauth_legacy`; it owns no
|
||||
signing key and no client credentials (the token it issues is cosmetic).
|
||||
Constructed per registration and stored in ``cfg`` — the views resolve it
|
||||
from ``hass.data`` per request, so a reload minting a fresh provider is
|
||||
transparent (no bound view captures the old one, unlike legacy mode).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._code_store = PKCECodeStore()
|
||||
|
||||
def issue_code(self, redirect_uri: str, code_challenge: str) -> str | None:
|
||||
"""Issue a one-shot PKCE-bound authorization code (see PKCECodeStore)."""
|
||||
return self._code_store.issue_code(redirect_uri, code_challenge)
|
||||
|
||||
def consume_code(self, code: str, redirect_uri: str, code_verifier: str) -> bool:
|
||||
"""Verify PKCE S256 + one-shot consume a code (see PKCECodeStore)."""
|
||||
return self._code_store.consume_code(code, redirect_uri, code_verifier)
|
||||
|
||||
@staticmethod
|
||||
def issue_access_token() -> str:
|
||||
"""Mint an opaque access token.
|
||||
|
||||
None mode ignores bearers (the secret webhook URL is the credential),
|
||||
so this token grants nothing — but it is a real random string, so a
|
||||
spec-strict client that stores/echoes it is satisfied.
|
||||
"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _active_autoapprove_provider(hass: HomeAssistant) -> AutoApproveProvider | None:
|
||||
"""The live none-mode auto-approve provider, or None when it is not live.
|
||||
|
||||
Read live from ``hass.data`` (not captured at view construction) so the
|
||||
bound views serve only while none-autoapprove is the active mode and 404
|
||||
otherwise — mirrors ``mcp_webhook._active_webhook_id``'s per-request gating.
|
||||
"""
|
||||
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):
|
||||
return None
|
||||
provider = cfg.get(CFG_AUTOAPPROVE_PROVIDER)
|
||||
return provider if isinstance(provider, AutoApproveProvider) else None
|
||||
|
||||
|
||||
class AutoApproveAuthorizeView(HomeAssistantView):
|
||||
"""None-mode auto-approve ``/authorize`` — issues a code, 302s, no UI.
|
||||
|
||||
Validates ``response_type=code``, PKCE S256, and the redirect_uri
|
||||
open-redirect gate, then issues a PKCE-bound one-time code and redirects
|
||||
straight back to the client. No login page and no consent screen render, so
|
||||
claude.ai's OAuth flow completes invisibly (issue #1969).
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
url = f"{OAUTH_BASE}/authorize"
|
||||
name = "ha_mcp_tools:oauth:autoapprove-authorize"
|
||||
|
||||
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:
|
||||
"""Auto-approve the authorization request or reject with a 400/404."""
|
||||
provider = _active_autoapprove_provider(self._hass)
|
||||
if provider is None:
|
||||
return _json_not_found()
|
||||
|
||||
params = request.query
|
||||
response_type = params.get("response_type", "")
|
||||
redirect_uri = params.get("redirect_uri", "")
|
||||
state = params.get("state", "")
|
||||
code_challenge = params.get("code_challenge", "")
|
||||
code_challenge_method = params.get("code_challenge_method", "")
|
||||
|
||||
if response_type != "code":
|
||||
return _json_error("unsupported_response_type", 400)
|
||||
if code_challenge_method != "S256":
|
||||
return _json_error(
|
||||
"invalid_request", 400, "code_challenge_method must be S256"
|
||||
)
|
||||
if not _PKCE_CHALLENGE_RE.match(code_challenge):
|
||||
return _json_error(
|
||||
"invalid_request", 400, "invalid code_challenge (43-char base64url)"
|
||||
)
|
||||
# SECURITY: an unvalidated redirect_uri would be an open redirector on
|
||||
# the HA origin. Reject in-place (never redirect) unless it exactly
|
||||
# matches a known MCP callback (client_id is attacker-controlled and is
|
||||
# deliberately not consulted — see module docstring).
|
||||
if not _is_valid_autoapprove_redirect(redirect_uri):
|
||||
return _json_error("invalid_request", 400, "invalid redirect_uri")
|
||||
|
||||
code = provider.issue_code(redirect_uri, code_challenge)
|
||||
if code is None:
|
||||
# Pending-code store at capacity (abuse guard) — surface per
|
||||
# RFC 6749 §4.1.2.1 instead of a silent failure.
|
||||
return _redirect_with(
|
||||
redirect_uri, error="temporarily_unavailable", state=state
|
||||
)
|
||||
redirect_params = {"code": code}
|
||||
if state:
|
||||
redirect_params["state"] = state
|
||||
return _redirect_with(redirect_uri, **redirect_params)
|
||||
|
||||
|
||||
class AutoApproveTokenView(HomeAssistantView):
|
||||
"""None-mode auto-approve ``/token`` — PKCE code → opaque access token.
|
||||
|
||||
Public client (no ``client_secret``): the PKCE code_verifier is the only
|
||||
proof required. The returned access token is cosmetic (none mode ignores
|
||||
bearers), but real and opaque. Only the ``authorization_code`` grant is
|
||||
supported — none mode has no refresh cycle.
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
url = f"{OAUTH_BASE}/token"
|
||||
name = "ha_mcp_tools:oauth:autoapprove-token"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; liveness is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
"""Exchange a PKCE authorization code for an opaque access token."""
|
||||
provider = _active_autoapprove_provider(self._hass)
|
||||
if provider is None:
|
||||
return _json_not_found()
|
||||
|
||||
form: dict[str, Any] = dict(await request.post())
|
||||
if form.get("grant_type", "") != "authorization_code":
|
||||
return _json_error("unsupported_grant_type", 400)
|
||||
|
||||
code = str(form.get("code", ""))
|
||||
redirect_uri = str(form.get("redirect_uri", ""))
|
||||
code_verifier = str(form.get("code_verifier", ""))
|
||||
if not (code and redirect_uri and code_verifier):
|
||||
return _json_error("invalid_request", 400)
|
||||
if not provider.consume_code(code, redirect_uri, code_verifier):
|
||||
return _json_error("invalid_grant", 400)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"access_token": provider.issue_access_token(),
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TOKEN_TTL,
|
||||
},
|
||||
headers=_TOKEN_RESPONSE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
def bind_autoapprove_views(hass: HomeAssistant) -> None:
|
||||
"""Bind the two auto-approve views at most once per HA session.
|
||||
|
||||
aiohttp cannot unregister a bound view, so a reload / re-enable / mode
|
||||
switch must reuse the already-bound views — they resolve the active
|
||||
provider from ``hass.data`` per request (see
|
||||
:func:`_active_autoapprove_provider`), so they serve only while
|
||||
none-autoapprove is live and 404 otherwise. The guard flag lives at a
|
||||
top-level ``hass.data`` key that survives config-entry teardown (mirrors
|
||||
:func:`mcp_webhook._register_metadata_views`).
|
||||
"""
|
||||
if hass.data.get(_AUTOAPPROVE_VIEWS_REGISTERED_KEY):
|
||||
return
|
||||
hass.http.register_view(AutoApproveAuthorizeView(hass))
|
||||
hass.http.register_view(AutoApproveTokenView(hass))
|
||||
hass.data[_AUTOAPPROVE_VIEWS_REGISTERED_KEY] = True
|
||||
Reference in New Issue
Block a user