6363
6464if 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 ,
0 commit comments