"""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