876 lines
40 KiB
Python
876 lines
40 KiB
Python
"""Unified scoped OAuth authorization endpoints (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 owns three path-scoped ``OAUTH_BASE`` endpoints. In none mode the
|
|
authorize/token pair completes 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.
|
|
* ``POST {OAUTH_BASE}/revoke`` fronts RFC 7009 revocation for ha_auth mode, the
|
|
only mode that hands the client a signed refresh envelope core cannot redeem
|
|
(#2248); it 404s in the other two.
|
|
|
|
The views dispatch per request from ``hass.data`` to the live legacy, ha_auth,
|
|
or none-mode provider (and 404 when no remote OAuth mode is live), mirroring the
|
|
discovery views so mode switches need no restart. The none-mode PKCE code store
|
|
and redirect-URI floor are reused from :mod:`oauth_legacy` rather than copied.
|
|
|
|
**Open-redirect policy.** In none mode THE SECRET WEBHOOK URL IS THE MAIN AND
|
|
ONLY FORM OF SECURITY. The OAuth surface exists purely for client compatibility;
|
|
its tokens grant nothing. ``/authorize`` therefore serves every provider and
|
|
302-redirects to any spec-valid ``redirect_uri``; malformed targets still hard
|
|
400 under :func:`oauth_legacy._is_valid_redirect_uri`. This makes the Home
|
|
Assistant origin usable as a crafted-link redirector, an accepted risk in the
|
|
secret-URL trust model. An exact-match callback allowlist shipped in PR #1976
|
|
in July 2026; it was retired on 2026-08-14 by maintainer decision to serve every
|
|
provider.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
from typing import TYPE_CHECKING, Any
|
|
from urllib.parse import urlencode, urlparse
|
|
|
|
import aiohttp
|
|
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,
|
|
_issuer_for,
|
|
read_form,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from homeassistant.core import HomeAssistant
|
|
from multidict import MultiDict
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
# 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"
|
|
|
|
# Dedicated aiohttp session for anonymous CIMD fetches in ha_auth mode.
|
|
# Keeping it separate from the relay session prevents slow public metadata
|
|
# endpoints from consuming the pool used by authenticated MCP forwarding.
|
|
CFG_CIMD_SESSION = "cimd_session"
|
|
|
|
# TOP-LEVEL hass.data flag recording that the three unified scoped 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"
|
|
|
|
|
|
# RFC 7009 §2.2.1: on a 503 from a revocation endpoint "the client must assume
|
|
# the token still exists and may retry after a reasonable delay", and the server
|
|
# MAY name that delay. Seconds, as a short transient — a client left to guess may
|
|
# simply give up, and an abandoned revocation leaves the session live (#2248).
|
|
_REVOKE_RETRY_AFTER = "5"
|
|
|
|
|
|
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 _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 _webhook_cfg(hass: HomeAssistant) -> dict[str, Any] | None:
|
|
"""The live webhook cfg dict, or None when the entry is not set up."""
|
|
domain_data = hass.data.get(DOMAIN)
|
|
if not isinstance(domain_data, dict):
|
|
return None
|
|
cfg = domain_data.get(DATA_WEBHOOK)
|
|
return cfg if isinstance(cfg, dict) else None
|
|
|
|
|
|
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.
|
|
"""
|
|
cfg = _webhook_cfg(hass)
|
|
if cfg is None:
|
|
return None
|
|
provider = cfg.get(CFG_AUTOAPPROVE_PROVIDER)
|
|
return provider if isinstance(provider, AutoApproveProvider) else None
|
|
|
|
|
|
def _validate_autoapprove_authorize(params: Any) -> web.Response | None:
|
|
"""Validate the none-mode /authorize query; a 400 Response, or None if OK.
|
|
|
|
Maintainer decision 2026-08-14 (supersedes the #1969-era exact-match
|
|
allowlist): none mode's ONLY credential is the secret webhook URL, so the
|
|
auto-approve flow completes invisibly for ANY spec-valid redirect — the
|
|
token it yields is cosmetic and grants nothing. The HA origin being usable
|
|
as a crafted-link redirector via this anonymous endpoint is an accepted
|
|
trade within that trust model. The spec floor (_is_valid_redirect_uri:
|
|
https or RFC 8252 loopback, valid port, no fragment) still hard-400s
|
|
malformed targets without redirecting.
|
|
"""
|
|
if params.get("response_type", "") != "code":
|
|
return _json_error("unsupported_response_type", 400)
|
|
if params.get("code_challenge_method", "") != "S256":
|
|
return _json_error("invalid_request", 400, "code_challenge_method must be S256")
|
|
if not _PKCE_CHALLENGE_RE.fullmatch(params.get("code_challenge", "")):
|
|
return _json_error(
|
|
"invalid_request", 400, "invalid code_challenge (43-char base64url)"
|
|
)
|
|
if not _is_valid_redirect_uri(params.get("redirect_uri", "")):
|
|
return _json_error("invalid_request", 400, "invalid redirect_uri")
|
|
return None
|
|
|
|
|
|
class AutoApproveAuthorizeView(HomeAssistantView):
|
|
"""Unified scoped ``/authorize`` dispatcher for every remote auth mode.
|
|
|
|
Legacy mode serves the shared consent flow, ha_auth redirects into core,
|
|
and none mode validates PKCE plus the redirect gate before issuing a code
|
|
and redirecting invisibly (issue #1969).
|
|
|
|
ACCEPTED RISK (issue #1978): this endpoint is anonymous by design — none
|
|
mode requires zero HA login — so it consults neither the webhook id nor a
|
|
client identity. Anyone who knows the HA origin can therefore fill the
|
|
shared pending-code store (``MAX_PENDING_CODES``) with S256 challenges bound
|
|
to the public claude.ai callback, at which point a *brand-new* connector's
|
|
handshake gets ``temporarily_unavailable`` until those codes expire
|
|
(``AUTH_CODE_TTL``, 5 min). Accepted because it is self-healing, exposes no
|
|
data, and grants no access: completing the flow needs the PKCE verifier the
|
|
attacker never has, and the issued token is cosmetic (none mode ignores
|
|
bearers). The webhook URL itself keeps forwarding throughout — only the rare
|
|
OAuth-discovery fallback for a *first* connect is briefly delayed.
|
|
"""
|
|
|
|
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:
|
|
"""Dispatch the authorization request to the active mode."""
|
|
cfg = _webhook_cfg(self._hass)
|
|
if cfg is None:
|
|
return _json_not_found()
|
|
legacy_provider = cfg.get("oauth_provider")
|
|
if legacy_provider is not None:
|
|
from .oauth_legacy import handle_legacy_authorize_get
|
|
|
|
return await handle_legacy_authorize_get(legacy_provider, request)
|
|
if cfg.get("resource_server") is not None:
|
|
return await self._ha_auth_authorize(cfg, request)
|
|
provider = cfg.get(CFG_AUTOAPPROVE_PROVIDER)
|
|
if not isinstance(provider, AutoApproveProvider):
|
|
return _json_not_found()
|
|
|
|
params = request.query
|
|
redirect_uri = params.get("redirect_uri", "")
|
|
state = params.get("state", "")
|
|
code_challenge = params.get("code_challenge", "")
|
|
|
|
err = _validate_autoapprove_authorize(params)
|
|
if err is not None:
|
|
return err
|
|
|
|
# RFC 9207: every authorization response — success or error — names the
|
|
# issuer that produced it, so a client registered with several
|
|
# authorization servers cannot be fed a response minted by another one.
|
|
iss = _issuer_for(request)
|
|
|
|
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, iss=iss
|
|
)
|
|
redirect_params = {"code": code, "iss": iss}
|
|
if state:
|
|
redirect_params["state"] = state
|
|
return _redirect_with(redirect_uri, **redirect_params)
|
|
|
|
async def _ha_auth_authorize(
|
|
self, cfg: dict[str, Any], request: web.Request
|
|
) -> web.Response:
|
|
"""302 the browser into core's /auth/authorize (ha_auth indirection).
|
|
|
|
The user logs in on core's own page exactly as before; only the URL the
|
|
client learned is ours. client_id is upgraded via CIMD/DCR validation
|
|
when possible, else passed through untouched (core stays the authority).
|
|
"""
|
|
from multidict import MultiDict
|
|
|
|
from .oauth_dcr import CFG_DCR_SIGNING_KEY
|
|
from .oauth_ha_auth import resolve_forward_client_id
|
|
|
|
# MultiDict copy: repeated OAuth params (e.g. RFC 8707 ``resource``)
|
|
# must survive the forward — a plain dict() collapses them.
|
|
params = MultiDict(request.query)
|
|
client_id = params.get("client_id", "")
|
|
redirect_uri = params.get("redirect_uri", "")
|
|
forward_id = await resolve_forward_client_id(
|
|
cfg.get(CFG_CIMD_SESSION),
|
|
cfg.get(CFG_DCR_SIGNING_KEY),
|
|
client_id,
|
|
redirect_uri,
|
|
)
|
|
if forward_id != client_id:
|
|
params.popall("client_id", None)
|
|
params["client_id"] = forward_id
|
|
|
|
# Keep the browser hop relative, matching the token leg. Browsers cannot
|
|
# be made to send X-Forwarded-Host, so this is consistency rather than a
|
|
# vulnerability fix.
|
|
|
|
# Percent-encode the query instead of handing the params to yarl: yarl
|
|
# legally leaves ":" and "/" literal inside query values (RFC 3986
|
|
# permits both in the query component), so a loopback client's callback
|
|
# forwards as ``redirect_uri=http://127.0.0.1:1234/callback``. Reverse
|
|
# proxies shipping a generic "block common exploits" ruleset -- Nginx
|
|
# Proxy Manager enables one per host with a checkbox -- match
|
|
# ``[a-zA-Z0-9_]=http://`` and answer 403 before core ever sees the
|
|
# request, stranding every native-app client behind such a proxy.
|
|
# Full encoding is semantically identical and survives those filters.
|
|
query = urlencode(list(params.items()))
|
|
target = f"/auth/authorize?{query}" if query else "/auth/authorize"
|
|
return web.Response(status=302, headers={"Location": target})
|
|
|
|
async def post(self, request: web.Request) -> web.Response:
|
|
"""Handle a legacy-mode consent submission on the scoped route."""
|
|
cfg = _webhook_cfg(self._hass)
|
|
if cfg is None:
|
|
return _json_not_found()
|
|
legacy_provider = cfg.get("oauth_provider")
|
|
if legacy_provider is None:
|
|
return _json_not_found()
|
|
from .oauth_legacy import handle_legacy_authorize_post
|
|
|
|
return await handle_legacy_authorize_post(legacy_provider, request)
|
|
|
|
|
|
def _revoke_rewrite(dcr_key: bytes | None, form: MultiDict) -> bool:
|
|
"""Swap an envelope back for core's own token on a revocation (#2248).
|
|
|
|
Core takes a revocation two ways — ``action=revoke&token=…`` on
|
|
``/auth/token`` (the IndieAuth 6.3.5 shape, which core marks deprecated in
|
|
favour of the view below but keeps for backwards compat) and the RFC 7009
|
|
``/auth/revoke`` view — and BOTH answer 200 even for a token they have
|
|
never seen, so a client revoking the envelope we handed it would get a
|
|
success it did not receive. The single place that knows how a revocation
|
|
token is unwrapped, shared by the token view's ``action=revoke`` branch and
|
|
:class:`AutoApproveRevokeView`. RFC 7009 authorizes the BEARER of the token
|
|
rather than a client identity, so the presenter binding is deliberately
|
|
skipped here. Returns whether the request must now be proxied (core has to
|
|
see its own token).
|
|
|
|
BOTH a verified envelope and a prefixed-but-unverifiable one are proxied
|
|
(#2249 review by Patch76). The unverifiable case is the ordinary one: every
|
|
envelope minted before the DCR signing key rotated — which is what
|
|
removing and re-adding the integration does — fails its MAC, and treating
|
|
it as "not ours" would 307 it to core, whose revoke endpoint answers 200
|
|
for any token it cannot resolve. The client would be told the session was
|
|
revoked while core's grant stayed live for its full 90 days. Forwarding a
|
|
body we did not verify is sound HERE and nowhere else: possession is the
|
|
only authorization a revocation needs, and core's endpoint is anonymous
|
|
and idempotent, so it grants a forger nothing they could not do by POSTing
|
|
to core directly. The refresh path keeps answering an INVALID envelope
|
|
locally and never forwards it.
|
|
"""
|
|
from .oauth_ha_auth import core_token_for_revocation
|
|
|
|
if dcr_key is None:
|
|
return False
|
|
core_refresh_token = core_token_for_revocation(dcr_key, str(form.get("token", "")))
|
|
if core_refresh_token is None:
|
|
return False
|
|
form.popall("token", None)
|
|
form["token"] = core_refresh_token
|
|
return True
|
|
|
|
|
|
def _envelope_identity(
|
|
dcr_key: bytes | None, form: MultiDict, client_id: str
|
|
) -> tuple[str, bool] | web.Response | None:
|
|
"""Read a refresh grant's identity out of its signed envelope (#2248).
|
|
|
|
None when the presented token carries no envelope (``ABSENT``) or no DCR
|
|
key is configured — the caller then falls through to the pre-envelope
|
|
derivation. A verified envelope rewrites ``form``'s ``refresh_token`` to
|
|
core's own token and forces the proxy leg, because a 307 would hand core a
|
|
value it cannot redeem. An ``INVALID`` one is answered locally for the same
|
|
reason: core cannot redeem it either, and relaying it would only feed its
|
|
failed-login accounting.
|
|
"""
|
|
from .oauth_ha_auth import EnvelopeState, unwrap_refresh_token
|
|
|
|
if dcr_key is None:
|
|
return None
|
|
envelope = unwrap_refresh_token(
|
|
dcr_key, str(form.get("refresh_token", "")), client_id
|
|
)
|
|
if isinstance(envelope, tuple):
|
|
core_refresh_token, forward_id = envelope
|
|
form.popall("refresh_token", None)
|
|
form["refresh_token"] = core_refresh_token
|
|
return forward_id, True
|
|
if envelope is EnvelopeState.INVALID:
|
|
_LOGGER.warning(
|
|
"ha_auth refresh: signed envelope failed verification for "
|
|
"client_id %s — the DCR signing key may have changed, or the "
|
|
"token was replayed under another client_id or tampered with",
|
|
client_id,
|
|
)
|
|
return _json_error(
|
|
"invalid_grant",
|
|
400,
|
|
"re-authorize: this refresh token could not be verified against "
|
|
"this server's current signing key",
|
|
)
|
|
return None
|
|
|
|
|
|
async def _pre_envelope_refresh_identity(
|
|
cfg: dict[str, Any], dcr_key: bytes | None, client_id: str
|
|
) -> tuple[str, bool] | web.Response:
|
|
"""Re-derive the identity of a redirect-less PRE-envelope refresh (#2248).
|
|
|
|
Only ``EnvelopeState.ABSENT`` tokens reach here — minted before the
|
|
envelope shipped, or presented while no DCR key is configured.
|
|
"""
|
|
from .oauth_ha_auth import RefreshDisposition, translated_client_id_for_refresh
|
|
|
|
translated = await translated_client_id_for_refresh(
|
|
cfg.get(CFG_CIMD_SESSION),
|
|
dcr_key,
|
|
client_id,
|
|
)
|
|
if translated is RefreshDisposition.UNREPRODUCIBLE:
|
|
# The token was bound to an origin nothing here can name, so core
|
|
# would reject it; answering locally keeps a guaranteed failure out
|
|
# of core's failed-login accounting. Registration still advertises
|
|
# refresh_token for these clients — one re-authorize mints an
|
|
# envelope-carrying token that refreshes from then on.
|
|
return _json_error(
|
|
"invalid_grant",
|
|
400,
|
|
"re-authorize once: this refresh token predates the signed "
|
|
"identity envelope, and its client's registration names no single "
|
|
"reproducible web origin to re-derive it from",
|
|
)
|
|
if translated is RefreshDisposition.PASSTHROUGH:
|
|
return client_id, False
|
|
return translated, False
|
|
|
|
|
|
async def _code_leg_forces_proxy(
|
|
cfg: dict[str, Any], dcr_key: bytes | None, client_id: str
|
|
) -> bool:
|
|
"""Whether an UNTRANSLATED code exchange must still be proxied (#2248).
|
|
|
|
The authorize leg's same-origin fast path returns the client_id without
|
|
fetching its CIMD document, which is right for that leg — but it means a
|
|
hybrid identity (redirects across two web origins, one of them same-origin
|
|
with the client_id) reaches core untranslated and gets core's RAW refresh
|
|
token back. Its redirect-less refresh then lands in
|
|
``translated_client_id_for_refresh``, which DOES fetch, sees the split
|
|
origins and answers UNREPRODUCIBLE — a local invalid_grant forever, under
|
|
a message promising that re-authorizing fixes it.
|
|
|
|
So the code leg pays the one CIMD fetch the fast path skipped: an
|
|
unreproducible identity is proxied instead of 307'd, its ``refresh_token``
|
|
comes back wrapped with forward id == client_id, and the refresh leg
|
|
proxies that same pair to core, which accepts it. A failed fetch or a
|
|
reproducible identity degrades to PASSTHROUGH and the 307, exactly as
|
|
before. Without a DCR key there is nothing to sign the envelope with, so
|
|
proxying would buy nothing.
|
|
"""
|
|
from .oauth_ha_auth import RefreshDisposition, translated_client_id_for_refresh
|
|
|
|
if dcr_key is None:
|
|
return False
|
|
try:
|
|
if urlparse(client_id).scheme != "https":
|
|
return False
|
|
except ValueError:
|
|
# Malformed client_id: core stays the authority (the authorize leg
|
|
# makes the same call on an anonymous view).
|
|
return False
|
|
disposition = await translated_client_id_for_refresh(
|
|
cfg.get(CFG_CIMD_SESSION),
|
|
dcr_key,
|
|
client_id,
|
|
)
|
|
return disposition is RefreshDisposition.UNREPRODUCIBLE
|
|
|
|
|
|
def _unavailable(description: str, *, revocation: bool) -> web.Response:
|
|
"""A 503 for a failed forward, carrying ``Retry-After`` on a revocation.
|
|
|
|
RFC 7009 §2.2.1 gives that status a specific meaning on a revocation
|
|
endpoint — the client must assume the token still exists and may retry
|
|
after a delay the server MAY name — and a client should not get a
|
|
different answer for spelling the same revocation as ``action=revoke`` on
|
|
``/token`` rather than posting it to the scoped ``/revoke`` view
|
|
(#2249 review by Patch76). Plain token failures stay bare: RFC 6749 gives
|
|
them no such retry contract.
|
|
"""
|
|
response = _json_error("temporarily_unavailable", 503, description)
|
|
if revocation:
|
|
response.headers["Retry-After"] = _REVOKE_RETRY_AFTER
|
|
return response
|
|
|
|
|
|
async def _forward_to_core(
|
|
hass: HomeAssistant, cfg: dict[str, Any], path: str, form: MultiDict
|
|
) -> tuple[int, bytes, str] | web.Response:
|
|
"""POST ``form`` to core's ``path`` server-side; its raw answer or a 503.
|
|
|
|
Shared by the token and revocation legs (#2248): both forward a rewritten
|
|
credential-bearing form to core over the relay session and map the same
|
|
transport failures, and only the token leg rewrites what comes back.
|
|
|
|
Whether this is a revocation is read from the request itself rather than
|
|
passed in, so both surfaces — the scoped ``/revoke`` view and ``/token``
|
|
with ``action=revoke`` — get the same 503, and neither call site has to
|
|
remember to say so.
|
|
"""
|
|
from .oauth_ha_auth import core_token_base_url
|
|
|
|
revocation = path == "/auth/revoke" or form.get("action") == "revoke"
|
|
session = cfg.get("session")
|
|
if session is None:
|
|
_LOGGER.warning(
|
|
"ha_auth %s forward: the entry has no relay session "
|
|
"(half-initialised setup); answering 503",
|
|
path,
|
|
)
|
|
return _unavailable("token forwarding is not available", revocation=revocation)
|
|
base = core_token_base_url(hass)
|
|
try:
|
|
async with session.post(
|
|
f"{base}{path}",
|
|
data=form,
|
|
timeout=aiohttp.ClientTimeout(total=25),
|
|
) as resp:
|
|
return (
|
|
resp.status,
|
|
await resp.read(),
|
|
resp.content_type or "application/json",
|
|
)
|
|
except (TimeoutError, aiohttp.ClientError) as err:
|
|
_LOGGER.warning(
|
|
"ha_auth %s forward to %s failed: %s",
|
|
path,
|
|
base,
|
|
type(err).__name__,
|
|
)
|
|
return _unavailable(
|
|
"core did not answer the token request", revocation=revocation
|
|
)
|
|
|
|
|
|
class AutoApproveTokenView(HomeAssistantView):
|
|
"""Unified scoped ``/token`` dispatcher for every remote auth mode.
|
|
|
|
Legacy mode uses the shared credentialed token handlers, ha_auth forwards
|
|
into core, and none mode exchanges a PKCE code as a public client for a
|
|
cosmetic opaque token (none mode ignores bearers and 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:
|
|
"""Dispatch the token request to the active mode."""
|
|
cfg = _webhook_cfg(self._hass)
|
|
if cfg is None:
|
|
return _json_not_found()
|
|
legacy_provider = cfg.get("oauth_provider")
|
|
if legacy_provider is not None:
|
|
from .oauth_legacy import handle_legacy_token_post
|
|
|
|
return await handle_legacy_token_post(legacy_provider, request)
|
|
if cfg.get("resource_server") is not None:
|
|
return await self._ha_auth_token(cfg, request)
|
|
provider = cfg.get(CFG_AUTOAPPROVE_PROVIDER)
|
|
if not isinstance(provider, AutoApproveProvider):
|
|
return _json_not_found()
|
|
|
|
raw_form = await read_form(request)
|
|
if raw_form is None:
|
|
return _json_error("invalid_request", 400)
|
|
form: dict[str, Any] = dict(raw_form)
|
|
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,
|
|
)
|
|
|
|
async def _ha_auth_token(
|
|
self, cfg: dict[str, Any], request: web.Request
|
|
) -> web.Response:
|
|
"""Route the token exchange to core: 307 by default, proxy if translating.
|
|
|
|
Untranslated identities are 307-redirected to core's own /auth/token so
|
|
core sees the client's real address (its wrong-login notifications, ban
|
|
counters, trusted_networks refresh validation, and last_used_ip all key
|
|
on request.remote — #2213 review). Only translated identities (the body
|
|
must be rewritten) are forwarded server-side; the translation matches
|
|
the authorize leg, and every forwarded 200 that is a grant response
|
|
comes back with its ``refresh_token`` wrapped in the signed envelope
|
|
that makes the next refresh resolvable (#2248).
|
|
"""
|
|
from multidict import MultiDict
|
|
|
|
from .oauth_dcr import CFG_DCR_SIGNING_KEY
|
|
|
|
raw_form = await read_form(request)
|
|
if raw_form is None:
|
|
return _json_error("invalid_request", 400)
|
|
# str()-coerce every value: request.post() also yields bytes and
|
|
# FileField on a multipart body, and those reach the outgoing
|
|
# session.post(data=form) serializer, which raises TypeError — an
|
|
# anonymous 500 (#2219 codex review). Repeated keys are preserved.
|
|
form: MultiDict = MultiDict(
|
|
(key, str(value)) for key, value in raw_form.items()
|
|
)
|
|
dcr_key = cfg.get(CFG_DCR_SIGNING_KEY)
|
|
client_id = str(form.get("client_id", ""))
|
|
resolved = await self._ha_auth_forward_identity(cfg, form, client_id, dcr_key)
|
|
if isinstance(resolved, web.Response):
|
|
return resolved
|
|
forward_id, proxy_required = resolved
|
|
if forward_id == client_id and not proxy_required:
|
|
# No body rewrite needed, so don't proxy: 307 the client into
|
|
# core's own /auth/token on the same public origin it just used.
|
|
# Core then observes the CLIENT's address, which it uses for more
|
|
# than logging (#2213 review by Patch76): process_wrong_login
|
|
# notifications and login_attempts_threshold ban counters on
|
|
# failed exchanges, trusted_networks refresh-token validation,
|
|
# and the profile's last_used_ip. 307 rather than 308: both
|
|
# preserve method+body, but a 308 is cacheable by default and
|
|
# could teach the client a core URL that outlives a later
|
|
# auth-mode switch — the exact stickiness this PR removes.
|
|
# RELATIVE Location (#2213 review round 2): an absolute target
|
|
# would be derived from unvalidated forwarded headers, turning a
|
|
# header a peer controls into the URL the client POSTS the grant
|
|
# to. A relative reference resolves against the origin the client
|
|
# actually used and keeps header derivation out of the credential
|
|
# path entirely (RFC 9110 permits relative Location).
|
|
return web.Response(
|
|
status=307,
|
|
headers={
|
|
"Location": "/auth/token",
|
|
"Cache-Control": "no-store",
|
|
},
|
|
)
|
|
# The body must be rewritten, so the exchange is forwarded server-side:
|
|
# a translated identity (cross-origin CIMD / DCR blob), an unwrapped
|
|
# envelope, a revocation whose token was one, or a code leg whose
|
|
# untranslated identity could not otherwise refresh (#2248). Core
|
|
# records this server's address for these rare clients — accepted
|
|
# residual, noted in the PR.
|
|
form.popall("client_id", None)
|
|
form["client_id"] = forward_id
|
|
return await self._proxy_token_to_core(
|
|
cfg, form, forward_id, client_id, dcr_key
|
|
)
|
|
|
|
async def _ha_auth_forward_identity(
|
|
self,
|
|
cfg: dict[str, Any],
|
|
form: MultiDict,
|
|
client_id: str,
|
|
dcr_key: bytes | None,
|
|
) -> tuple[str, bool] | web.Response:
|
|
"""Resolve the client_id to present to core, plus whether proxying is forced.
|
|
|
|
Returns a ready ``web.Response`` instead when the grant must be
|
|
answered locally. Mutates ``form`` wherever the wire value is one of
|
|
ours and core must receive its own: the envelope in a refresh grant,
|
|
and the envelope in a revocation's ``token``.
|
|
|
|
Envelope first (#2248). A refresh token we wrapped names the client_id
|
|
core bound it to, so the identity is READ rather than re-derived, and
|
|
the exchange must be proxied — a 307 would hand core an envelope it
|
|
cannot redeem. Anything else keeps the pre-#2248 behavior: a refresh
|
|
carrying a redirect_uri translates from that redirect exactly like the
|
|
authorize leg, a redirect-less refresh re-derives from the registered
|
|
list, and a verified registration with no reproducible origin is
|
|
answered locally rather than 307'd into a guaranteed core failure.
|
|
"""
|
|
from .oauth_ha_auth import resolve_forward_client_id
|
|
|
|
grant_type = str(form.get("grant_type", ""))
|
|
redirect_uri = str(form.get("redirect_uri", ""))
|
|
if form.get("action") == "revoke":
|
|
# RFC 7009 revocation carries no grant_type; the only rewrite it
|
|
# needs is the envelope swap, and everything else 307s as before.
|
|
return client_id, _revoke_rewrite(dcr_key, form)
|
|
if grant_type == "refresh_token":
|
|
envelope = _envelope_identity(dcr_key, form, client_id)
|
|
if envelope is not None:
|
|
return envelope
|
|
if not client_id:
|
|
return client_id, False
|
|
if grant_type == "refresh_token" and not redirect_uri:
|
|
return await _pre_envelope_refresh_identity(cfg, dcr_key, client_id)
|
|
# Authorization-code exchanges — and refreshes that DO carry a
|
|
# redirect_uri — use the presented redirect, exactly like the authorize
|
|
# leg. With no redirect_uri, validation leaves the client_id untouched
|
|
# for core to reject authoritatively.
|
|
forward_id = await resolve_forward_client_id(
|
|
cfg.get(CFG_CIMD_SESSION),
|
|
dcr_key,
|
|
client_id,
|
|
redirect_uri,
|
|
)
|
|
if grant_type == "authorization_code" and forward_id == client_id:
|
|
return client_id, await _code_leg_forces_proxy(cfg, dcr_key, client_id)
|
|
return forward_id, False
|
|
|
|
async def _proxy_token_to_core(
|
|
self,
|
|
cfg: dict[str, Any],
|
|
form: MultiDict,
|
|
forward_id: str,
|
|
client_id: str,
|
|
dcr_key: bytes | None,
|
|
) -> web.Response:
|
|
"""POST the rewritten token form to core and relay its response.
|
|
|
|
A 200 has its ``refresh_token`` wrapped before it leaves (#2248) so the
|
|
client's next refresh carries the identity core bound this grant to.
|
|
Every other status — and a body with nothing to wrap — is relayed
|
|
byte-for-byte.
|
|
"""
|
|
from .oauth_ha_auth import rewrite_token_response_body
|
|
|
|
forwarded = await _forward_to_core(self._hass, cfg, "/auth/token", form)
|
|
if isinstance(forwarded, web.Response):
|
|
return forwarded
|
|
status, body, content_type = forwarded
|
|
# Revocation answers 200 with an EMPTY body, so there is nothing to
|
|
# rewrite and a warning would be pure noise.
|
|
if status == 200 and dcr_key is not None and form.get("action") != "revoke":
|
|
body = rewrite_token_response_body(dcr_key, body, forward_id, client_id)
|
|
return web.Response(
|
|
status=status,
|
|
body=body,
|
|
content_type=content_type,
|
|
headers=_TOKEN_RESPONSE_HEADERS,
|
|
)
|
|
|
|
|
|
class AutoApproveRevokeView(HomeAssistantView):
|
|
"""Scoped RFC 7009 ``/revoke`` endpoint, served in ha_auth mode only (#2248).
|
|
|
|
Core's own ``POST /auth/revoke`` answers 200 for a token it has never seen
|
|
(RFC 7009 §2.2, which core's ``RevokeTokenView`` cites verbatim), so a
|
|
client that posts the signed envelope we handed it straight to core gets a
|
|
silent no-op and keeps a live session. Fronting revocation here is what
|
|
lets the envelope be swapped for core's own token first. Legacy and none
|
|
mode never mint an envelope, so this route 404s there — the envelope is the
|
|
whole reason it exists.
|
|
|
|
ANONYMOUS BY DESIGN, like core's own revocation view (``requires_auth =
|
|
False``, ``cors_allowed = True``, mirrored here): RFC 7009 authorizes the
|
|
BEARER of the token, not a client identity. That grants no new reach. A
|
|
token carrying no ``hamcp-rt-`` prefix never causes an outbound request at
|
|
all — it 307s and this server makes no call. A prefixed one IS forwarded
|
|
even when its MAC does not verify, which is what keeps revocation working
|
|
across a signing-key rotation (#2249 review) and hands a forger nothing:
|
|
core's revoke endpoint is anonymous and idempotent and answers 200 to
|
|
whatever they could already POST to it directly. See
|
|
:func:`_revoke_rewrite`.
|
|
"""
|
|
|
|
requires_auth = False
|
|
cors_allowed = True
|
|
url = f"{OAUTH_BASE}/revoke"
|
|
name = "ha_mcp_tools:oauth:autoapprove-revoke"
|
|
|
|
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:
|
|
"""Unwrap an envelope and forward, or 307 the revocation into core."""
|
|
cfg = _webhook_cfg(self._hass)
|
|
if cfg is None or cfg.get("resource_server") is None:
|
|
return _json_not_found()
|
|
from multidict import MultiDict
|
|
|
|
from .oauth_dcr import CFG_DCR_SIGNING_KEY
|
|
|
|
raw_form = await read_form(request)
|
|
if raw_form is None:
|
|
return _json_error("invalid_request", 400)
|
|
# str()-coerced MultiDict for the same reason the token view builds
|
|
# one: bytes/FileField values from a multipart body would raise a
|
|
# TypeError inside the outgoing serializer (#2219).
|
|
form: MultiDict = MultiDict(
|
|
(key, str(value)) for key, value in raw_form.items()
|
|
)
|
|
if not _revoke_rewrite(cfg.get(CFG_DCR_SIGNING_KEY), form):
|
|
# No core token to recover from the body, so there is nothing
|
|
# to rewrite: 307 the client into core's own /auth/revoke, which
|
|
# then observes the CLIENT's address. Relative Location for the
|
|
# token view's reason — an absolute one would derive the
|
|
# credential target from unvalidated forwarded headers (#2213
|
|
# review round 2).
|
|
return web.Response(
|
|
status=307,
|
|
headers={
|
|
"Location": "/auth/revoke",
|
|
"Cache-Control": "no-store",
|
|
},
|
|
)
|
|
forwarded = await _forward_to_core(self._hass, cfg, "/auth/revoke", form)
|
|
if isinstance(forwarded, web.Response):
|
|
# The helper's only Response is the 503, and it already carries
|
|
# the RFC 7009 §2.2.1 Retry-After for every revocation, whichever
|
|
# surface it arrived on.
|
|
return forwarded
|
|
status, body, content_type = forwarded
|
|
# Relayed as-is: core answers a revocation with an empty 200, so there
|
|
# is never a refresh_token to wrap on the way back.
|
|
return web.Response(
|
|
status=status,
|
|
body=body,
|
|
content_type=content_type,
|
|
headers=_TOKEN_RESPONSE_HEADERS,
|
|
)
|
|
|
|
|
|
def bind_autoapprove_views(hass: HomeAssistant) -> None:
|
|
"""Bind the three unified OAuth 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
|
|
mode/provider from ``hass.data`` per request (see :func:`_webhook_cfg`), so
|
|
the same paths dispatch to legacy, ha_auth, or none-autoapprove without
|
|
rebinding. 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
|
|
# Set the flag only AFTER ALL THREE views register (issue #1978): see
|
|
# mcp_webhook._register_metadata_views. Marking the bundle bound before
|
|
# /token or /revoke registers would let a later setup assign its mode
|
|
# provider and advertise OAuth with an unbound endpoint the discovery
|
|
# document names — a 404 on the token exchange or on revocation (#2248).
|
|
# The flag must mean the full bundle succeeded; a partial bind leaves it
|
|
# unset.
|
|
hass.http.register_view(AutoApproveAuthorizeView(hass))
|
|
hass.http.register_view(AutoApproveTokenView(hass))
|
|
hass.http.register_view(AutoApproveRevokeView(hass))
|
|
hass.data[_AUTOAPPROVE_VIEWS_REGISTERED_KEY] = True
|