Skip to content

Commit 8c7e028

Browse files
kingpanther13claude
andcommitted
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
1 parent d971ed5 commit 8c7e028

13 files changed

Lines changed: 1202 additions & 199 deletions

File tree

custom_components/ha_mcp_tools/oauth_autoapprove.py

Lines changed: 124 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363

6464
if TYPE_CHECKING:
6565
from homeassistant.core import HomeAssistant
66+
from multidict import MultiDict
6667

6768

6869
# cfg (hass.data[DOMAIN][DATA_WEBHOOK]) key holding the live AutoApproveProvider.
@@ -315,6 +316,13 @@ class AutoApproveTokenView(HomeAssistantView):
315316
Legacy mode uses the shared credentialed token handlers, ha_auth forwards
316317
into core, and none mode exchanges a PKCE code as a public client for a
317318
cosmetic opaque token (none mode ignores bearers and has no refresh cycle).
319+
320+
ha_auth's forwarded 200s carry a REWRITTEN ``refresh_token`` (issue #2248):
321+
the value handed to the client is a signed envelope naming the client_id
322+
core bound the grant to, which the refresh leg unwraps back into core's own
323+
token. That is what makes loopback-callback and multi-origin clients
324+
refreshable — nothing here has to re-derive an origin the registration
325+
cannot name.
318326
"""
319327

320328
requires_auth = False
@@ -376,20 +384,13 @@ async def _ha_auth_token(
376384
counters, trusted_networks refresh validation, and last_used_ip all key
377385
on request.remote — #2213 review). Only translated identities (the body
378386
must be rewritten) are forwarded server-side; the translation matches
379-
the authorize leg. A refresh carrying a redirect_uri translates from
380-
that redirect like any other leg; a redirect-less refresh re-derives
381-
the translation from the registered list, and verified identities with
382-
no reproducible origin get a local invalid_grant (re-authorize).
387+
the authorize leg, and every forwarded 200 comes back with its
388+
``refresh_token`` wrapped in the signed envelope that makes the next
389+
refresh resolvable (#2248).
383390
"""
384391
from multidict import MultiDict
385392

386393
from .oauth_dcr import CFG_DCR_SIGNING_KEY
387-
from .oauth_ha_auth import (
388-
RefreshDisposition,
389-
core_token_base_url,
390-
resolve_forward_client_id,
391-
translated_client_id_for_refresh,
392-
)
393394

394395
raw_form = await read_form(request)
395396
if raw_form is None:
@@ -401,50 +402,13 @@ async def _ha_auth_token(
401402
form: MultiDict = MultiDict(
402403
(key, str(value)) for key, value in raw_form.items()
403404
)
404-
grant_type = str(form.get("grant_type", ""))
405+
dcr_key = cfg.get(CFG_DCR_SIGNING_KEY)
405406
client_id = str(form.get("client_id", ""))
406-
redirect_uri = str(form.get("redirect_uri", ""))
407-
forward_id = client_id
408-
if client_id:
409-
if grant_type == "refresh_token" and not redirect_uri:
410-
# refresh_token grant without a redirect_uri on the wire —
411-
# re-derive the translation from the registered list alone.
412-
translated = await translated_client_id_for_refresh(
413-
cfg.get(CFG_CIMD_SESSION),
414-
cfg.get(CFG_DCR_SIGNING_KEY),
415-
client_id,
416-
)
417-
if translated is RefreshDisposition.UNREPRODUCIBLE:
418-
# Coupled to oauth_dcr registration semantics: a VERIFIED
419-
# identity (DCR blob or fetched CIMD document — #2217
420-
# review closed the CIMD half of this guard) whose
421-
# registration has no single reproducible web origin must
422-
# not advertise refresh_token, because this guard rejects
423-
# its redirect-less refresh locally. The token was bound
424-
# to an origin we cannot re-derive; answering here avoids
425-
# a guaranteed failure in core's failed-login accounting.
426-
return _json_error(
427-
"invalid_grant",
428-
400,
429-
"re-authorize: this client's registration has no "
430-
"single reproducible web origin, so a refresh "
431-
"without redirect_uri is unavailable",
432-
)
433-
if translated is not RefreshDisposition.PASSTHROUGH:
434-
forward_id = translated
435-
else:
436-
# Authorization-code exchanges — and refreshes that DO carry a
437-
# redirect_uri — use the presented redirect, exactly like the
438-
# authorize leg (this is what keeps multi-origin identities
439-
# refreshable). With no redirect_uri, validation leaves the
440-
# client_id untouched for core to reject authoritatively.
441-
forward_id = await resolve_forward_client_id(
442-
cfg.get(CFG_CIMD_SESSION),
443-
cfg.get(CFG_DCR_SIGNING_KEY),
444-
client_id,
445-
redirect_uri,
446-
)
447-
if forward_id == client_id:
407+
resolved = await self._ha_auth_forward_identity(cfg, form, dcr_key)
408+
if isinstance(resolved, web.Response):
409+
return resolved
410+
forward_id, proxy_required = resolved
411+
if forward_id == client_id and not proxy_required:
448412
# No body rewrite needed, so don't proxy: 307 the client into
449413
# core's own /auth/token on the same public origin it just used.
450414
# Core then observes the CLIENT's address, which it uses for more
@@ -468,12 +432,111 @@ async def _ha_auth_token(
468432
"Cache-Control": "no-store",
469433
},
470434
)
471-
# Translated identity (cross-origin CIMD / DCR blob): the body must be
472-
# rewritten, so the exchange is forwarded server-side. Core records
473-
# this server's address for these rare clients — accepted residual,
474-
# noted in the PR.
435+
# Translated identity (cross-origin CIMD / DCR blob, or an unwrapped
436+
# envelope): the body must be rewritten, so the exchange is forwarded
437+
# server-side. Core records this server's address for these rare
438+
# clients — accepted residual, noted in the PR.
475439
form.popall("client_id", None)
476440
form["client_id"] = forward_id
441+
return await self._proxy_token_to_core(
442+
cfg, form, forward_id, client_id, dcr_key
443+
)
444+
445+
async def _ha_auth_forward_identity(
446+
self, cfg: dict[str, Any], form: MultiDict, dcr_key: bytes | None
447+
) -> tuple[str, bool] | web.Response:
448+
"""The client_id to present to core, plus whether proxying is forced.
449+
450+
Returns a ready ``web.Response`` instead when the grant must be
451+
answered locally. Mutates ``form`` in the envelope case: the wire value
452+
of ``refresh_token`` is our envelope, and core must receive the token
453+
it minted.
454+
455+
Envelope first (#2248). A refresh token we wrapped names the client_id
456+
core bound it to, so the identity is READ rather than re-derived, and
457+
the exchange must be proxied — a 307 would hand core an envelope it
458+
cannot redeem. Anything else keeps the pre-#2248 behavior: a refresh
459+
carrying a redirect_uri translates from that redirect exactly like the
460+
authorize leg, a redirect-less refresh re-derives from the registered
461+
list, and a verified registration with no reproducible origin is
462+
answered locally rather than 307'd into a guaranteed core failure.
463+
"""
464+
from .oauth_ha_auth import (
465+
RefreshDisposition,
466+
resolve_forward_client_id,
467+
translated_client_id_for_refresh,
468+
unwrap_refresh_token,
469+
)
470+
471+
grant_type = str(form.get("grant_type", ""))
472+
client_id = str(form.get("client_id", ""))
473+
redirect_uri = str(form.get("redirect_uri", ""))
474+
if grant_type == "refresh_token" and dcr_key is not None:
475+
envelope = unwrap_refresh_token(
476+
dcr_key, str(form.get("refresh_token", "")), client_id
477+
)
478+
if envelope is not None:
479+
core_refresh_token, forward_id = envelope
480+
form.popall("refresh_token", None)
481+
form["refresh_token"] = core_refresh_token
482+
return forward_id, True
483+
if not client_id:
484+
return client_id, False
485+
if grant_type == "refresh_token" and not redirect_uri:
486+
# A pre-#2248 refresh token (or a tampered envelope): the identity
487+
# was never recorded, so re-derive it from the registered list.
488+
translated = await translated_client_id_for_refresh(
489+
cfg.get(CFG_CIMD_SESSION),
490+
dcr_key,
491+
client_id,
492+
)
493+
if translated is RefreshDisposition.UNREPRODUCIBLE:
494+
# The token was bound to an origin nothing here can name, so
495+
# core would reject it; answering locally keeps a guaranteed
496+
# failure out of core's failed-login accounting. Registration
497+
# still advertises refresh_token for these clients — one
498+
# re-authorize mints an envelope-carrying token that refreshes
499+
# from then on.
500+
return _json_error(
501+
"invalid_grant",
502+
400,
503+
"re-authorize once: this refresh token predates the "
504+
"signed identity envelope and its client's registration "
505+
"names no single reproducible web origin, so "
506+
"re-authorizing is what makes the session refreshable",
507+
)
508+
if translated is RefreshDisposition.PASSTHROUGH:
509+
return client_id, False
510+
return translated, False
511+
# Authorization-code exchanges — and refreshes that DO carry a
512+
# redirect_uri — use the presented redirect, exactly like the authorize
513+
# leg. With no redirect_uri, validation leaves the client_id untouched
514+
# for core to reject authoritatively.
515+
forward_id = await resolve_forward_client_id(
516+
cfg.get(CFG_CIMD_SESSION),
517+
dcr_key,
518+
client_id,
519+
redirect_uri,
520+
)
521+
return forward_id, False
522+
523+
async def _proxy_token_to_core(
524+
self,
525+
cfg: dict[str, Any],
526+
form: MultiDict,
527+
forward_id: str,
528+
client_id: str,
529+
dcr_key: bytes | None,
530+
) -> web.Response:
531+
"""POST the rewritten token form to core and relay its response.
532+
533+
A 200 has its ``refresh_token`` wrapped before it leaves (#2248) so the
534+
client's next refresh carries the identity core bound this grant to.
535+
Every other status — and a body with nothing to wrap — is relayed
536+
byte-for-byte.
537+
"""
538+
from .oauth_ha_auth import core_token_base_url, rewrite_token_response_body
539+
477540
session = cfg.get("session")
478541
if session is None:
479542
return _json_error("temporarily_unavailable", 503)
@@ -485,6 +548,10 @@ async def _ha_auth_token(
485548
timeout=aiohttp.ClientTimeout(total=25),
486549
) as resp:
487550
body = await resp.read()
551+
if resp.status == 200 and dcr_key is not None:
552+
body = rewrite_token_response_body(
553+
dcr_key, body, forward_id, client_id
554+
)
488555
return web.Response(
489556
status=resp.status,
490557
body=body,

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)