Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,24 @@ mode** option in the entry options:
system-generated users are rejected. This is distinct from the beta OAuth mode
below — no bespoke authorization server or self-issued token is involved, and
revoking the user's Home Assistant token/session revokes access.
The component-scoped authorize/token endpoints front Core's own `/auth/*`
(a browser redirect and a server-side token forward) so the URLs clients
cache are the component's — Core remains the authorization authority and
performs its own validation on every request. For URL-shaped client
The component-scoped authorize/token/revoke endpoints front Core's own
`/auth/*` (a browser redirect, a server-side token forward, and an RFC 7009
revocation forward) so the URLs clients cache are the component's — Core
remains the authorization authority and performs its own validation on every
request. Revocation is fronted because the refresh token the client holds is
a signed envelope naming the identity Core bound the grant to, and Core
answers 200 for a token it does not recognise: posting the envelope to Core
directly would report a revocation that never happened. The scoped endpoint
is anonymous exactly as Core's own is (RFC 7009 authorizes the bearer of the
token, not a client identity). It makes no outbound request for a token that
is not one of its own envelopes; a prefixed one is forwarded even when its
signature does not verify, which is what keeps revocation working after the
signing key rotates (removing and re-adding the integration mints a new one).
That grants a forger nothing: possession is the only authorization a
revocation needs, and Core's revocation endpoint is anonymous and idempotent,
so an unverified body could just as well have been posted to Core directly.
The refresh path is the strict one — an envelope whose signature it cannot
verify is answered locally and never forwarded. For URL-shaped client
identities Core would reject (cross-origin Client ID Metadata Document
clients), the component validates the CIMD document itself per the MCP
2026-07-28 requirements (https-only fetch with no redirects, 10 KiB cap,
Expand Down
9 changes: 9 additions & 0 deletions custom_components/ha_mcp_tools/mcp_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,21 @@ def _authorization_server_document(base: str) -> dict[str, Any]:
stickiness). ``registration_endpoint`` serves DCR-fallback brokers;
both CIMD-selection flags stay pinned (see
test_as_documents_pin_the_claude_cimd_selection_contract).

``revocation_endpoint`` is ours for a second reason (#2248): the refresh
token the client holds is a signed envelope, and core's own
``/auth/revoke`` answers 200 without revoking anything for a value it
cannot recognise. Only ha_auth mints those, so only this document
advertises it. The endpoint takes no client authentication, matching
``token_endpoint_auth_methods_supported``.
"""
return {
"issuer": f"{base}{OAUTH_BASE}",
"authorization_endpoint": f"{base}{OAUTH_BASE}/authorize",
"token_endpoint": f"{base}{OAUTH_BASE}/token",
"registration_endpoint": f"{base}{OAUTH_BASE}/register",
"revocation_endpoint": f"{base}{OAUTH_BASE}/revoke",
"revocation_endpoint_auth_methods_supported": ["none"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
Expand Down
525 changes: 436 additions & 89 deletions custom_components/ha_mcp_tools/oauth_autoapprove.py

Large diffs are not rendered by default.

29 changes: 17 additions & 12 deletions custom_components/ha_mcp_tools/oauth_dcr.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,14 @@ def _non_loopback_origins(redirect_uris: list[str]) -> set[tuple[str, str, int]]


def _refresh_identity_is_reproducible(redirect_uris: list[str]) -> bool:
"""Return whether every callback maps to exactly one stable web origin."""
"""Return whether every callback maps to exactly one stable web origin.

Read only by ``oauth_ha_auth.translated_client_id_for_refresh``, which
handles refresh tokens minted before the signed envelope shipped (#2248).
Registration no longer gates the advertised grant types on this: an
envelope records the translated identity at mint time, so a registration
shape that cannot be re-derived is still refreshable.
"""
if len(_non_loopback_origins(redirect_uris)) != 1:
return False
return not any(
Expand Down Expand Up @@ -188,23 +195,21 @@ def _redirect_uris_error(value: Any) -> tuple[str, str] | None:
return None


def _active_grant_types(hass: HomeAssistant, redirect_uris: list[str]) -> list[str]:
def _active_grant_types(hass: HomeAssistant) -> list[str]:
"""Grant types the ACTIVE mode actually implements (RFC 7591 honesty).

none mode's auto-approve token endpoint rejects refresh grants and its AS
document advertises only ``authorization_code`` — the registration response
must not promise more. ha_auth forwards to core, but refresh is advertised
only when every callback maps to exactly one reproducible non-loopback
origin. Multiple web origins and ephemeral loopback origins cannot be
reconstructed for a redirect_uri-less refresh grant without server state.
must not promise more. ha_auth forwards to core and promises refresh for
EVERY valid registration (#2248): a translated identity refreshes off the
signed envelope the token leg mints, and an untranslated one refreshes at
core directly. The registration shape no longer decides it — the envelope
carries the identity, so ephemeral loopback ports and multi-origin
registrations refresh like anything else.
"""
domain_data = hass.data.get(DOMAIN)
cfg = domain_data.get(DATA_WEBHOOK) if isinstance(domain_data, dict) else None
if (
isinstance(cfg, dict)
and cfg.get("resource_server") is not None
and _refresh_identity_is_reproducible(redirect_uris)
):
if isinstance(cfg, dict) and cfg.get("resource_server") is not None:
return ["authorization_code", "refresh_token"]
return ["authorization_code"]

Expand Down Expand Up @@ -284,7 +289,7 @@ async def post(self, request: web.Request) -> web.Response:
"client_id_issued_at": int(time.time()),
"redirect_uris": uris,
"token_endpoint_auth_method": "none",
"grant_types": _active_grant_types(self._hass, uris),
"grant_types": _active_grant_types(self._hass),
"response_types": ["code"],
}
# Echo benign metadata the client sent (RFC 7591 §3.2.1 lets the AS
Expand Down
Loading
Loading