Skip to content

Commit abac722

Browse files
geofffranksclaude
andcommitted
fix(oauth): unwrap U-Tec {code,data} token envelope + surface INVALID_TOKEN
U-Tec's /token endpoint returns the OAuth token nested under {"code":200,"data":{access_token,...}} instead of the RFC-6749 fields at the top level. HA's stock OAuth2 implementations look for access_token/expires_in at the top level, so token refresh silently produced no usable token. The access token then went stale/revoked and every API call came back as an HTTP-200 INVALID_TOKEN error envelope, which the integration treated as success -- so entities froze on last-known state with no error, no unavailable, and no reauth prompt. Two fixes, both in custom_components/u_tec: 1. Unwrap the envelope (oauth.py): _unwrap_utec_token lifts data.{...} to the top level for both the authorization-code and refresh-token grants. Wired via UtecAuthImplementation (runtime/refresh, through application_credentials.async_get_auth_implementation) and UtecLocalOAuth2Implementation (config-flow auth/reauth). 2. Surface auth failure (coordinator.py): _raise_for_error_payload maps payload.error INVALID_TOKEN -> ConfigEntryAuthFailed (HA triggers reauth + marks entities unavailable) and other codes -> UpdateFailed, from both the poll and discovery. Discovery uses auth_only so a revoked token surfaces at setup/reload instead of silently building 0 devices (which wiped entities). Also fixes a stale test: HA core changed LocalOAuth2Implementation.name from "Configuration.yaml" to "Local application credentials"; assert the durable behavior (static "U-Tec" entry title) rather than HA's internal label. Tests: tests/test_oauth.py, tests/test_oauth_wiring.py, and coordinator error-envelope cases in tests/test_coordinator.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4c51f9e commit abac722

8 files changed

Lines changed: 316 additions & 9 deletions

File tree

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
"""Application credentials platform for the Uhome integration."""
22

3-
from homeassistant.components.application_credentials import AuthorizationServer
3+
from homeassistant.components.application_credentials import (
4+
AuthorizationServer,
5+
ClientCredential,
6+
)
47
from homeassistant.core import HomeAssistant
8+
from homeassistant.helpers import config_entry_oauth2_flow
59

610
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
11+
from .oauth import UtecAuthImplementation
712

813

914
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
@@ -12,3 +17,20 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe
1217
authorize_url=OAUTH2_AUTHORIZE,
1318
token_url=OAUTH2_TOKEN,
1419
)
20+
21+
22+
async def async_get_auth_implementation(
23+
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
24+
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
25+
"""Return a custom auth implementation that unwraps U-Tec's token envelope.
26+
27+
Defining this hook overrides application_credentials' default
28+
AuthImplementation for the runtime (refresh) path, so token refresh can
29+
parse U-Tec's non-standard ``{code,data}`` response. See ``oauth.py``.
30+
"""
31+
return UtecAuthImplementation(
32+
hass,
33+
auth_domain,
34+
credential,
35+
await async_get_authorization_server(hass),
36+
)

custom_components/u_tec/config_flow.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
OAUTH2_AUTHORIZE,
4949
OAUTH2_TOKEN,
5050
)
51+
from .oauth import UtecLocalOAuth2Implementation
5152

5253

5354
OPTIMISTIC_MODE_ALL = "all"
@@ -140,7 +141,10 @@ async def async_step_replace_credentials(
140141
)
141142

142143
self._pending_credential = ClientCredential(client_id, client_secret)
143-
self.flow_impl = config_entry_oauth2_flow.LocalOAuth2Implementation(
144+
# Unwrapping implementation: U-Tec's /token returns the OAuth fields
145+
# nested under {"code","data"}, which HA's stock implementation can't
146+
# parse. See oauth.py.
147+
self.flow_impl = UtecLocalOAuth2Implementation(
144148
self.hass,
145149
DOMAIN,
146150
client_id,

custom_components/u_tec/coordinator.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,34 @@
2626
_LOGGER = logging.getLogger(__name__)
2727

2828

29+
def _raise_for_error_payload(response, *, auth_only: bool = False) -> None:
30+
"""Surface U-Tec error envelopes returned with an HTTP 2xx status.
31+
32+
U-Tec replies HTTP 200 even on failure, carrying the error under
33+
``payload.error`` (e.g. ``{"code": "INVALID_TOKEN", "message": ...}``).
34+
Left unraised, a revoked/expired token is swallowed and the coordinator
35+
serves stale state indefinitely with no reauth prompt. ``INVALID_TOKEN``
36+
becomes ``ConfigEntryAuthFailed`` (so HA triggers reauth and marks entities
37+
unavailable); other error codes become ``UpdateFailed`` unless ``auth_only``
38+
is set — discovery only needs to surface auth failures and lets other
39+
errors fall through to its existing graceful handling.
40+
"""
41+
if not isinstance(response, dict):
42+
return
43+
payload = response.get("payload")
44+
if not isinstance(payload, dict):
45+
return
46+
error = payload.get("error")
47+
if not isinstance(error, dict):
48+
return
49+
code = error.get("code")
50+
message = error.get("message", "")
51+
if code == "INVALID_TOKEN":
52+
raise ConfigEntryAuthFailed(f"U-Tec rejected access token: {message}")
53+
if not auth_only:
54+
raise UpdateFailed(f"U-Tec API error {code}: {message}")
55+
56+
2957
class UhomeDataUpdateCoordinator(DataUpdateCoordinator):
3058
"""Class to manage fetching Uhome data."""
3159

@@ -88,6 +116,11 @@ async def async_discover_devices(self) -> None:
88116
_LOGGER.error("Invalid discovery data received: %s", discovery_data)
89117
return
90118

119+
# A revoked token returns an INVALID_TOKEN envelope here; surface it so
120+
# setup/reload fails into reauth instead of silently building 0 devices
121+
# (which wipes every entity to unavailable on reload).
122+
_raise_for_error_payload(discovery_data, auth_only=True)
123+
91124
devices_data = discovery_data.get("payload", {}).get("devices", [])
92125
_LOGGER.debug("Found %s devices in discovery data", len(devices_data))
93126

@@ -152,6 +185,11 @@ async def _async_update_data(self) -> dict[str, dict]:
152185
except ApiError as err:
153186
raise UpdateFailed(f"Error communicating with API: {err}") from err
154187

188+
# U-Tec returns HTTP 200 with an error envelope (e.g. INVALID_TOKEN) that
189+
# get_device_state does not raise on — surface it instead of treating an
190+
# error response as an empty-but-successful poll.
191+
_raise_for_error_payload(response)
192+
155193
if response and "payload" in response:
156194
for device_data in response["payload"].get("devices", []):
157195
device_id = device_data.get("id")

custom_components/u_tec/oauth.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Custom OAuth2 implementations for U-Tec.
2+
3+
U-Tec's token endpoint (``https://oauth.u-tec.com/token``) returns the OAuth
4+
token wrapped in a non-standard envelope::
5+
6+
{"code": 200, "data": {"access_token": ..., "token_type": "Bearer",
7+
"expires_in": 601200, "scope": "openapi", ...}}
8+
9+
Home Assistant's stock OAuth2 implementations expect the RFC-6749 fields at the
10+
top level, so without unwrapping the refresh (and authorization-code) grant
11+
silently produces no usable token and the access token goes stale. These
12+
subclasses unwrap the envelope for both paths: the config-flow
13+
``LocalOAuth2Implementation`` and the runtime application-credentials
14+
``AuthImplementation``.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from typing import Any
20+
21+
from homeassistant.components.application_credentials import AuthImplementation
22+
from homeassistant.helpers import config_entry_oauth2_flow
23+
24+
25+
def _unwrap_utec_token(result: Any) -> dict:
26+
"""Lift U-Tec's nested token payload to the top level.
27+
28+
Accepts both the wrapped ``{"code","data":{...}}`` shape and an
29+
already-standard response (passthrough). Raises ``ValueError`` when no
30+
``access_token`` is present (an error envelope), so the caller fails loudly
31+
instead of persisting a token dict with no usable access token.
32+
"""
33+
if isinstance(result, dict) and "access_token" in result:
34+
return result
35+
if isinstance(result, dict):
36+
data = result.get("data")
37+
if isinstance(data, dict) and "access_token" in data:
38+
return data
39+
raise ValueError(f"U-Tec token response missing access_token: {result!r}")
40+
41+
42+
class _UtecTokenUnwrapMixin:
43+
"""Mixin unwrapping U-Tec's ``{code,data}`` token envelope.
44+
45+
Overrides ``_token_request`` — used by both the authorization-code and
46+
refresh-token grants — to normalise the response before HA consumes it.
47+
"""
48+
49+
async def _token_request(self, data: dict) -> dict:
50+
return _unwrap_utec_token(await super()._token_request(data))
51+
52+
53+
class UtecLocalOAuth2Implementation(
54+
_UtecTokenUnwrapMixin, config_entry_oauth2_flow.LocalOAuth2Implementation
55+
):
56+
"""Config-flow LocalOAuth2Implementation with U-Tec envelope unwrapping."""
57+
58+
59+
class UtecAuthImplementation(_UtecTokenUnwrapMixin, AuthImplementation):
60+
"""Runtime application-credentials AuthImplementation with envelope unwrapping."""

tests/test_config_flow.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,12 @@ async def test_async_oauth_create_entry_builds_entry(hass):
7474
async def test_async_oauth_create_entry_title_independent_of_flow_impl_name(hass):
7575
"""Title uses a static integration name, not flow_impl.name.
7676
77-
LocalOAuth2Implementation.name returns the literal string "Configuration.yaml"
78-
(hardcoded in HA core for yaml-configured impls). The deferred-credential
79-
flow builds a LocalOAuth2Implementation directly, so using flow_impl.name
80-
as the entry title would produce a confusing "Configuration.yaml" entry in
81-
the UI. Title must be a static, recognisable integration name.
77+
LocalOAuth2Implementation.name returns a generic HA-internal label whose
78+
exact value is version-dependent (e.g. "Configuration.yaml" on older cores,
79+
"Local application credentials" on current ones). The deferred-credential
80+
flow builds a LocalOAuth2Implementation directly, so using flow_impl.name as
81+
the entry title would surface that confusing label in the UI. Title must be a
82+
static, recognisable integration name regardless of the HA-core string.
8283
"""
8384
from homeassistant.helpers.config_entry_oauth2_flow import (
8485
LocalOAuth2Implementation,
@@ -92,8 +93,10 @@ async def test_async_oauth_create_entry_title_independent_of_flow_impl_name(hass
9293
handler.flow_impl = LocalOAuth2Implementation(
9394
hass, DOMAIN, "test-id", "test-secret", OAUTH2_AUTHORIZE, OAUTH2_TOKEN,
9495
)
95-
# Sanity check: HA core really does return this literal string.
96-
assert handler.flow_impl.name == "Configuration.yaml"
96+
# flow_impl.name is a generic HA-internal label (version-dependent) and,
97+
# crucially, NOT our integration name — so the static title asserted below
98+
# cannot have been derived from it.
99+
assert handler.flow_impl.name != "U-Tec"
97100

98101
result = await handler.async_oauth_create_entry({"token": {"access_token": "t"}})
99102

tests/test_coordinator.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,59 @@ async def test_discover_device_missing_id_is_skipped(coordinator, mock_uhome_api
281281
}
282282
await coordinator.async_discover_devices()
283283
assert coordinator.devices == {}
284+
285+
286+
# --- error-envelope surfacing (U-Tec returns HTTP 200 with payload.error) ---
287+
288+
289+
async def test_async_update_data_invalid_token_payload_raises_config_entry_auth_failed(
290+
coordinator, mock_uhome_api,
291+
):
292+
"""A revoked/expired token comes back as an HTTP-200 INVALID_TOKEN envelope.
293+
294+
It must surface as ConfigEntryAuthFailed (→ reauth + entities unavailable),
295+
not be swallowed as success (which left HA serving stale state silently).
296+
"""
297+
coordinator.devices["sw-1"] = make_fake_switch("sw-1")
298+
mock_uhome_api.get_device_state.return_value = {
299+
"payload": {"error": {"code": "INVALID_TOKEN", "message": "expired"}}
300+
}
301+
with pytest.raises(ConfigEntryAuthFailed):
302+
await coordinator._async_update_data()
303+
304+
305+
async def test_async_update_data_other_error_payload_raises_update_failed(
306+
coordinator, mock_uhome_api,
307+
):
308+
coordinator.devices["sw-1"] = make_fake_switch("sw-1")
309+
mock_uhome_api.get_device_state.return_value = {
310+
"payload": {"error": {"code": "INTERNAL_ERROR", "message": "boom"}}
311+
}
312+
with pytest.raises(UpdateFailed):
313+
await coordinator._async_update_data()
314+
315+
316+
async def test_async_update_data_success_payload_still_returns(coordinator, mock_uhome_api):
317+
"""Regression guard: a normal devices payload is unaffected by the error check."""
318+
sw = make_fake_switch("sw-1")
319+
sw.get_state_data = lambda: {"st.switch": {"switch": "on"}}
320+
coordinator.devices["sw-1"] = sw
321+
mock_uhome_api.get_device_state.return_value = {
322+
"payload": {"devices": [
323+
{"id": "sw-1", "states": [{"capability": "st.switch", "name": "switch", "value": "on"}]},
324+
]}
325+
}
326+
result = await coordinator._async_update_data()
327+
assert "sw-1" in result
328+
329+
330+
async def test_discover_invalid_token_payload_raises_config_entry_auth_failed(
331+
coordinator, mock_uhome_api,
332+
):
333+
"""A bad token during discovery must surface (→ reauth), not silently yield
334+
zero devices — which on reload wipes every entity to unavailable."""
335+
mock_uhome_api.discover_devices.return_value = {
336+
"payload": {"error": {"code": "INVALID_TOKEN", "message": "expired"}}
337+
}
338+
with pytest.raises(ConfigEntryAuthFailed):
339+
await coordinator.async_discover_devices()

tests/test_oauth.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Tests for U-Tec OAuth token-envelope unwrapping.
2+
3+
U-Tec's /token endpoint wraps the token under {"code","data":{...}} instead of
4+
returning the RFC-6749 fields at the top level. Home Assistant's stock OAuth2
5+
implementation looks for access_token/expires_in at the top level, so without
6+
unwrapping a token refresh silently yields no usable token and the access token
7+
goes stale. These cover the unwrap and its wiring into the implementations.
8+
"""
9+
10+
from aioresponses import aioresponses
11+
import pytest
12+
13+
from custom_components.u_tec.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN
14+
from custom_components.u_tec.oauth import (
15+
UtecLocalOAuth2Implementation,
16+
_unwrap_utec_token,
17+
)
18+
19+
_WRAPPED = {
20+
"code": 200,
21+
"data": {
22+
"access_token": "abc123",
23+
"token_type": "Bearer",
24+
"expires_in": 601200,
25+
"scope": "openapi",
26+
"refresh_token": "r-456",
27+
},
28+
}
29+
30+
31+
def test_unwrap_lifts_nested_data_to_top_level():
32+
assert _unwrap_utec_token(_WRAPPED) == _WRAPPED["data"]
33+
34+
35+
def test_unwrap_passthrough_standard_response():
36+
standard = {"access_token": "xyz", "token_type": "Bearer", "expires_in": 3600}
37+
assert _unwrap_utec_token(standard) == standard
38+
39+
40+
def test_unwrap_raises_when_no_access_token_present():
41+
with pytest.raises(ValueError):
42+
_unwrap_utec_token({"code": 401, "data": {"message": "bad refresh token"}})
43+
44+
45+
async def test_local_impl_token_request_unwraps(hass):
46+
"""_token_request against the real {code,data} envelope returns standard fields."""
47+
impl = UtecLocalOAuth2Implementation(
48+
hass, DOMAIN, "client-id", "client-secret", OAUTH2_AUTHORIZE, OAUTH2_TOKEN,
49+
)
50+
with aioresponses() as mock:
51+
mock.post(OAUTH2_TOKEN, payload=_WRAPPED)
52+
result = await impl._token_request(
53+
{"grant_type": "refresh_token", "refresh_token": "r"}
54+
)
55+
assert result["access_token"] == "abc123"
56+
assert result["expires_in"] == 601200
57+
assert result["refresh_token"] == "r-456"

tests/test_oauth_wiring.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""The unwrapping OAuth implementations must be wired into both code paths.
2+
3+
Runtime token refresh resolves its implementation via the application_credentials
4+
platform hook; the config flow builds its own in-memory implementation. Both must
5+
use the U-Tec envelope-unwrapping classes, or refresh/auth silently break.
6+
"""
7+
8+
from unittest.mock import AsyncMock, patch
9+
10+
from aioresponses import aioresponses
11+
12+
from custom_components.u_tec.const import DOMAIN, OAUTH2_TOKEN
13+
from custom_components.u_tec.oauth import (
14+
UtecAuthImplementation,
15+
UtecLocalOAuth2Implementation,
16+
)
17+
18+
_WRAPPED = {
19+
"code": 200,
20+
"data": {
21+
"access_token": "abc123",
22+
"token_type": "Bearer",
23+
"expires_in": 601200,
24+
"scope": "openapi",
25+
},
26+
}
27+
28+
29+
async def test_application_credentials_returns_unwrapping_implementation(hass):
30+
"""Runtime path: the app-creds hook returns an unwrapping implementation."""
31+
from homeassistant.components.application_credentials import ClientCredential
32+
33+
from custom_components.u_tec.application_credentials import (
34+
async_get_auth_implementation,
35+
)
36+
37+
impl = await async_get_auth_implementation(
38+
hass, DOMAIN, ClientCredential("client-id", "client-secret")
39+
)
40+
assert isinstance(impl, UtecAuthImplementation)
41+
42+
with aioresponses() as mock:
43+
mock.post(OAUTH2_TOKEN, payload=_WRAPPED)
44+
result = await impl._token_request(
45+
{"grant_type": "refresh_token", "refresh_token": "r"}
46+
)
47+
assert result["access_token"] == "abc123"
48+
assert result["expires_in"] == 601200
49+
50+
51+
async def test_config_flow_uses_unwrapping_implementation(hass):
52+
"""Config-flow path: replace-credentials sets an unwrapping flow_impl."""
53+
from custom_components.u_tec.config_flow import UhomeOAuth2FlowHandler
54+
55+
handler = UhomeOAuth2FlowHandler()
56+
handler.hass = hass
57+
58+
with patch.object(
59+
UhomeOAuth2FlowHandler,
60+
"async_step_auth",
61+
new=AsyncMock(return_value={"type": "external_step"}),
62+
):
63+
await handler.async_step_replace_credentials(
64+
{"client_id": "client-id", "client_secret": "client-secret"}
65+
)
66+
67+
assert isinstance(handler.flow_impl, UtecLocalOAuth2Implementation)

0 commit comments

Comments
 (0)