Skip to content

Commit 79b3a57

Browse files
fix(component): make loopback and multi-origin DCR clients refreshable in ha_auth mode (#2249)
* fix(component): make loopback and multi-origin DCR clients refreshable in ha_auth mode Core binds a refresh token to the client_id the code leg presented; for translated DCR/CIMD identities a redirect_uri-less refresh grant carried nothing to re-derive it (ephemeral loopback ports, multi-origin registrations), so the token view answered a local invalid_grant while the code leg still handed out core's refresh token. Every loopback-callback client re-authorized on each 30-minute access-token expiry (#2248). Record the identity at mint time instead: wrap the refresh_token of every server-side-forwarded 200 in an HMAC-signed envelope carrying core's token, the bound client_id, and a digest of the presenter; the refresh leg unwraps it and proxies the exact pair to core. DCR in ha_auth mode now advertises refresh_token for every registration. Pre-envelope tokens keep the previous derivation path and migrate on a single re-authorize. Mirrored into the dev webhook proxy with the required dev version bump. Fixes #2248 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162BCYnuBnew5cdVoDs4Roc * fix(component): answer invalid envelopes locally, unwrap on revoke, wrap hybrid CIMD code exchanges Review round on #2249: - Split unwrap_refresh_token's failure into ABSENT (no prefix: a pre-envelope token, falls through to the legacy derivation) and INVALID (our prefix, bad MAC / presenter / shape / version): INVALID is logged and answered with a local invalid_grant instead of 307'd into core's failed-login accounting, and the pre-envelope message no longer claims a cause it cannot know. - Core's action=revoke on /auth/token answers 200 for unknown tokens, so a client revoking the envelope would silently no-op; unwrap it there (no presenter binding, RFC 7009 authorizes the bearer) and proxy. - A hybrid CIMD identity presenting its same-origin redirect took the code-leg fast path untranslated, got core's raw token, and stayed in the re-auth loop; the code leg now pays the one CIMD fetch and proxies an unreproducible identity so its token is wrapped. - Log transport failures and half-initialised config on the core token forward (with a 503 description), log non-JSON 200s, and replace the bare assert on the anonymous refresh path with a guarded fallback. - Mirror the component's str()-coerced MultiDict form into the proxy's ha_auth token view (#2219 hardening had drifted). - Tests: mirror-side MAC tamper/wrong-key/non-200/byte-identity cases, disjointness tests that exercise the MAC via prefix relabelling, refresh-leg re-wrap, payload-shape and keyless-ha_auth cases, revoke cases, looser error_description assertions, one duplicate removed. - Docs/comments: stale MIRROR header, DOCS.md migration clause, trimmed repeated pre-envelope prose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162BCYnuBnew5cdVoDs4Roc * fix(component): front RFC 7009 revocation so envelope-wrapped refresh tokens can be revoked Core's /auth/revoke answers 200 for a token it does not recognise, so a client revoking the signed envelope directly at core would get a silent no-op and keep a live session. Add a scoped /revoke view in both trees: an envelope is unwrapped (bearer-authorized, no presenter binding) and forwarded server-side to core's /auth/revoke; a plain token 307s into core so core observes the client's address; 503 carries Retry-After per RFC 7009 section 2.2.1. Served and advertised (revocation_endpoint, revocation_endpoint_auth_methods_supported: ["none"]) in ha_auth mode only; none and legacy documents and behaviour are unchanged. The token and revoke legs share one core-forward helper. SECURITY.md and the dev proxy DOCS.md describe the fronted endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162BCYnuBnew5cdVoDs4Roc * test: account for the scoped /revoke view in the registered-view pins The embedded-entry prebind counts and the add-on proxy view-set tests pin the number and URLs of the bound OAuth views; the scoped revocation dispatcher (#2248) is one more. The add-on tests feature-detect it like the other routes so the stable flavor keeps its expectation until promotion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162BCYnuBnew5cdVoDs4Roc * test: count the scoped /revoke view in the remaining registered-view pins The component webhook registration tests and the add-on proxy's none / ha_auth / reload / unload view-set tests pin the bound OAuth view count and URLs; the scoped revocation dispatcher (#2248) binds with the authorize/token pair in every mode. The add-on tests feature-detect it so the stable flavor keeps its expectation until promotion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162BCYnuBnew5cdVoDs4Roc * fix(component): recover the core token from an unverifiable envelope on revocation Review by Patch76 on #2249: a token carrying the hamcp-rt- prefix whose MAC does not verify took the same branch as a plain core token and was 307'd to core, whose revoke endpoint answers 200 for anything it cannot resolve — so the client was told the session was revoked while core's grant lived out its 90 days. The ordinary way in is a signing-key rotation: removing and re-adding the integration mints a new key and invalidates every outstanding envelope. core_token_for_revocation() now parses the core token out of a prefixed value's body even when the MAC fails, and only on the revocation legs. Sound here and nowhere else: RFC 7009 authorizes the bearer of a token rather than a client, and core's revoke endpoint is anonymous and idempotent, so forwarding an unverified body grants a forger nothing they could not get by POSTing to core directly. The refresh leg still answers an INVALID envelope locally and never forwards it. The unverified parse is length-capped before decoding and catches RecursionError, which only it can meet. Retry-After now rides every revocation 503, whether the request arrived at the scoped /revoke view or as action=revoke on /token. Tests pin both revocation surfaces in both trees: tampered and rotated-key envelopes forwarded unwrapped, prefixed values carrying no usable token 307'd, over-cap values not parsed, and the Retry-After split between revocation and plain token failures. SECURITY.md and DOCS.md narrow the no-outbound-request claim to the refresh path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162BCYnuBnew5cdVoDs4Roc --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d971ed5 commit 79b3a57

19 files changed

Lines changed: 3757 additions & 307 deletions

SECURITY.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,24 @@ mode** option in the entry options:
189189
system-generated users are rejected. This is distinct from the beta OAuth mode
190190
below — no bespoke authorization server or self-issued token is involved, and
191191
revoking the user's Home Assistant token/session revokes access.
192-
The component-scoped authorize/token endpoints front Core's own `/auth/*`
193-
(a browser redirect and a server-side token forward) so the URLs clients
194-
cache are the component's — Core remains the authorization authority and
195-
performs its own validation on every request. For URL-shaped client
192+
The component-scoped authorize/token/revoke endpoints front Core's own
193+
`/auth/*` (a browser redirect, a server-side token forward, and an RFC 7009
194+
revocation forward) so the URLs clients cache are the component's — Core
195+
remains the authorization authority and performs its own validation on every
196+
request. Revocation is fronted because the refresh token the client holds is
197+
a signed envelope naming the identity Core bound the grant to, and Core
198+
answers 200 for a token it does not recognise: posting the envelope to Core
199+
directly would report a revocation that never happened. The scoped endpoint
200+
is anonymous exactly as Core's own is (RFC 7009 authorizes the bearer of the
201+
token, not a client identity). It makes no outbound request for a token that
202+
is not one of its own envelopes; a prefixed one is forwarded even when its
203+
signature does not verify, which is what keeps revocation working after the
204+
signing key rotates (removing and re-adding the integration mints a new one).
205+
That grants a forger nothing: possession is the only authorization a
206+
revocation needs, and Core's revocation endpoint is anonymous and idempotent,
207+
so an unverified body could just as well have been posted to Core directly.
208+
The refresh path is the strict one — an envelope whose signature it cannot
209+
verify is answered locally and never forwarded. For URL-shaped client
196210
identities Core would reject (cross-origin Client ID Metadata Document
197211
clients), the component validates the CIMD document itself per the MCP
198212
2026-07-28 requirements (https-only fetch with no redirects, 10 KiB cap,

custom_components/ha_mcp_tools/mcp_webhook.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,21 @@ def _authorization_server_document(base: str) -> dict[str, Any]:
157157
stickiness). ``registration_endpoint`` serves DCR-fallback brokers;
158158
both CIMD-selection flags stay pinned (see
159159
test_as_documents_pin_the_claude_cimd_selection_contract).
160+
161+
``revocation_endpoint`` is ours for a second reason (#2248): the refresh
162+
token the client holds is a signed envelope, and core's own
163+
``/auth/revoke`` answers 200 without revoking anything for a value it
164+
cannot recognise. Only ha_auth mints those, so only this document
165+
advertises it. The endpoint takes no client authentication, matching
166+
``token_endpoint_auth_methods_supported``.
160167
"""
161168
return {
162169
"issuer": f"{base}{OAUTH_BASE}",
163170
"authorization_endpoint": f"{base}{OAUTH_BASE}/authorize",
164171
"token_endpoint": f"{base}{OAUTH_BASE}/token",
165172
"registration_endpoint": f"{base}{OAUTH_BASE}/register",
173+
"revocation_endpoint": f"{base}{OAUTH_BASE}/revoke",
174+
"revocation_endpoint_auth_methods_supported": ["none"],
166175
"response_types_supported": ["code"],
167176
"grant_types_supported": ["authorization_code", "refresh_token"],
168177
"code_challenge_methods_supported": ["S256"],

custom_components/ha_mcp_tools/oauth_autoapprove.py

Lines changed: 436 additions & 89 deletions
Large diffs are not rendered by default.

custom_components/ha_mcp_tools/oauth_dcr.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,14 @@ def _non_loopback_origins(redirect_uris: list[str]) -> set[tuple[str, str, int]]
156156

157157

158158
def _refresh_identity_is_reproducible(redirect_uris: list[str]) -> bool:
159-
"""Return whether every callback maps to exactly one stable web origin."""
159+
"""Return whether every callback maps to exactly one stable web origin.
160+
161+
Read only by ``oauth_ha_auth.translated_client_id_for_refresh``, which
162+
handles refresh tokens minted before the signed envelope shipped (#2248).
163+
Registration no longer gates the advertised grant types on this: an
164+
envelope records the translated identity at mint time, so a registration
165+
shape that cannot be re-derived is still refreshable.
166+
"""
160167
if len(_non_loopback_origins(redirect_uris)) != 1:
161168
return False
162169
return not any(
@@ -188,23 +195,21 @@ def _redirect_uris_error(value: Any) -> tuple[str, str] | None:
188195
return None
189196

190197

191-
def _active_grant_types(hass: HomeAssistant, redirect_uris: list[str]) -> list[str]:
198+
def _active_grant_types(hass: HomeAssistant) -> list[str]:
192199
"""Grant types the ACTIVE mode actually implements (RFC 7591 honesty).
193200
194201
none mode's auto-approve token endpoint rejects refresh grants and its AS
195202
document advertises only ``authorization_code`` — the registration response
196-
must not promise more. ha_auth forwards to core, but refresh is advertised
197-
only when every callback maps to exactly one reproducible non-loopback
198-
origin. Multiple web origins and ephemeral loopback origins cannot be
199-
reconstructed for a redirect_uri-less refresh grant without server state.
203+
must not promise more. ha_auth forwards to core and promises refresh for
204+
EVERY valid registration (#2248): a translated identity refreshes off the
205+
signed envelope the token leg mints, and an untranslated one refreshes at
206+
core directly. The registration shape no longer decides it — the envelope
207+
carries the identity, so ephemeral loopback ports and multi-origin
208+
registrations refresh like anything else.
200209
"""
201210
domain_data = hass.data.get(DOMAIN)
202211
cfg = domain_data.get(DATA_WEBHOOK) if isinstance(domain_data, dict) else None
203-
if (
204-
isinstance(cfg, dict)
205-
and cfg.get("resource_server") is not None
206-
and _refresh_identity_is_reproducible(redirect_uris)
207-
):
212+
if isinstance(cfg, dict) and cfg.get("resource_server") is not None:
208213
return ["authorization_code", "refresh_token"]
209214
return ["authorization_code"]
210215

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

0 commit comments

Comments
 (0)