Skip to content

Commit 80359bf

Browse files
authored
Continue central error handling for OAuth (home-assistant#180588)
1 parent f945b3d commit 80359bf

6 files changed

Lines changed: 519 additions & 58 deletions

File tree

homeassistant/components/cloud/account_link.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ def domain(self) -> str:
113113
"""Domain that is providing the implementation."""
114114
return DOMAIN
115115

116+
@property
117+
@override
118+
def service_domain(self) -> str:
119+
"""Domain of the service the tokens are for."""
120+
return self.service
121+
116122
@override
117123
async def async_generate_authorize_url(self, flow_id: str) -> str:
118124
"""Generate a url for the user to authorize."""

homeassistant/exceptions.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import Callable, Generator, Sequence
44
from typing import TYPE_CHECKING, Any, override
55

6-
from aiohttp import ClientResponse, ClientResponseError, RequestInfo
6+
from aiohttp import ClientError, ClientResponse, ClientResponseError, RequestInfo
77
from multidict import MultiMapping
88

99
from .util.event_type import EventType
@@ -253,8 +253,20 @@ class ConfigEntryAuthFailed(IntegrationError):
253253
"""Error to indicate that config entry could not authenticate."""
254254

255255

256-
class OAuth2TokenRequestError(ClientResponseError, HomeAssistantError):
257-
"""Error to indicate that the OAuth 2.0 flow could not refresh token."""
256+
class OAuth2TokenRequestBaseError(ConfigEntryNotReady):
257+
"""Base class for the errors a failed OAuth 2.0 token request raises.
258+
259+
Catch this to handle every token request failure; the subclasses differ in
260+
whether a status was received and what should happen to the config entry.
261+
"""
262+
263+
264+
class OAuth2TokenRequestError(ClientResponseError, OAuth2TokenRequestBaseError):
265+
"""Error to indicate that the OAuth 2.0 flow could not refresh token.
266+
267+
Inherits ConfigEntryNotReady so setup retries without the integration having to
268+
map it. Catch it explicitly to handle it differently.
269+
"""
258270

259271
def __init__(
260272
self,
@@ -275,15 +287,32 @@ def __init__(
275287
message=message,
276288
headers=headers,
277289
)
278-
HomeAssistantError.__init__(self)
290+
OAuth2TokenRequestBaseError.__init__(self)
279291
self.domain = domain
280292
self.translation_domain = "homeassistant"
281293
self.translation_key = "oauth2_helper_refresh_failed"
282294
self.translation_placeholders = {"domain": domain}
283295
self.generate_message = True
284296

285297

286-
class OAuth2TokenRequestTransientError(OAuth2TokenRequestError, ConfigEntryNotReady):
298+
class OAuth2TokenRequestConnectionError(ClientError, OAuth2TokenRequestBaseError):
299+
"""Recoverable error to indicate the token request yielded no usable token.
300+
301+
Covers a request that never got a response and one whose response could not
302+
be used, neither of which has a status to tell the causes apart.
303+
"""
304+
305+
def __init__(self, *, domain: str) -> None:
306+
"""Initialize OAuth2TokenRequestConnectionError."""
307+
OAuth2TokenRequestBaseError.__init__(self)
308+
self.domain = domain
309+
self.translation_domain = "homeassistant"
310+
self.translation_key = "oauth2_helper_refresh_transient"
311+
self.translation_placeholders = {"domain": domain}
312+
self.generate_message = True
313+
314+
315+
class OAuth2TokenRequestTransientError(OAuth2TokenRequestError):
287316
"""Recoverable error to indicate flow could not refresh token.
288317
289318
Inherits ConfigEntryNotReady so setup retries without the integration having to

homeassistant/helpers/config_entry_oauth2_flow.py

Lines changed: 65 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import logging
1818
import secrets
1919
import time
20-
from typing import Any, cast, override
20+
from typing import Any, NoReturn, cast, override
2121

2222
from aiohttp import ClientError, ClientResponseError, client, hdrs, web
2323
from habluetooth import BluetoothServiceInfoBleak
@@ -30,6 +30,7 @@
3030
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
3131
from homeassistant.exceptions import (
3232
ImplementationUnavailableError,
33+
OAuth2TokenRequestConnectionError,
3334
OAuth2TokenRequestError,
3435
OAuth2TokenRequestReauthError,
3536
OAuth2TokenRequestTransientError,
@@ -105,6 +106,28 @@
105106
)
106107

107108

109+
def _raise_mapped_token_error(err: ClientError, domain: str) -> NoReturn:
110+
"""Re-raise a failed token request as the matching OAuth2 token error."""
111+
if not isinstance(err, ClientResponseError):
112+
# Nothing was received, so there is no status to tell the causes apart.
113+
_LOGGER.debug("Token request for %s got no response: %s", domain, err)
114+
raise OAuth2TokenRequestConnectionError(domain=domain) from err
115+
116+
kwargs: dict[str, Any] = {
117+
"request_info": err.request_info,
118+
"history": err.history,
119+
"status": err.status,
120+
"message": err.message,
121+
"headers": err.headers,
122+
"domain": domain,
123+
}
124+
if err.status == HTTPStatus.TOO_MANY_REQUESTS or 500 <= err.status <= 599:
125+
raise OAuth2TokenRequestTransientError(**kwargs) from err
126+
if 400 <= err.status <= 499:
127+
raise OAuth2TokenRequestReauthError(**kwargs) from err
128+
raise OAuth2TokenRequestError(**kwargs) from err
129+
130+
108131
@callback
109132
def async_get_redirect_uri(hass: HomeAssistant) -> str:
110133
"""Return the redirect uri."""
@@ -163,11 +186,30 @@ async def async_resolve_external_data(self, external_data: Any) -> dict:
163186
config entry data.
164187
"""
165188

189+
@property
190+
def service_domain(self) -> str:
191+
"""Domain of the service the tokens are for.
192+
193+
Defaults to the implementation itself, but an implementation that obtains
194+
tokens on behalf of other integrations has to name the one it serves.
195+
"""
196+
return self.domain
197+
166198
async def async_refresh_token(self, token: dict) -> dict:
167199
"""Refresh a token and update expires info."""
168-
new_token = await self._async_refresh_token(token)
200+
try:
201+
new_token = await self._async_refresh_token(token)
202+
except OAuth2TokenRequestError, OAuth2TokenRequestConnectionError:
203+
raise
204+
except ClientError as err:
205+
# Implementations that issue their own token request may not map their
206+
# failures, so callers would see a raw aiohttp error instead.
207+
_raise_mapped_token_error(err, self.service_domain)
169208
# Force int for non-compliant oauth2 providers
170-
new_token["expires_in"] = int(new_token["expires_in"])
209+
try:
210+
new_token["expires_in"] = int(new_token["expires_in"])
211+
except (KeyError, TypeError, ValueError) as err:
212+
raise OAuth2TokenRequestConnectionError(domain=self.service_domain) from err
171213
new_token["expires_at"] = time.time() + new_token["expires_in"]
172214
return new_token
173215

@@ -268,6 +310,11 @@ async def _async_refresh_token(self, token: dict) -> dict:
268310
}
269311
)
270312

313+
# Merging a response without one would keep the stale access token while
314+
# extending its expiry, so the session would never recover.
315+
if not new_token.get("access_token"):
316+
raise OAuth2TokenRequestConnectionError(domain=self.service_domain)
317+
271318
return {**token, **new_token}
272319

273320
async def _token_request(self, data: dict) -> dict:
@@ -306,38 +353,13 @@ async def _token_request(self, data: dict) -> dict:
306353
detail,
307354
)
308355
resp.raise_for_status()
356+
return cast(dict, await resp.json())
309357
except ClientResponseError as err:
310-
if err.status == HTTPStatus.TOO_MANY_REQUESTS or 500 <= err.status <= 599:
311-
# Recoverable error
312-
raise OAuth2TokenRequestTransientError(
313-
request_info=err.request_info,
314-
history=err.history,
315-
status=err.status,
316-
message=err.message,
317-
headers=err.headers,
318-
domain=self._domain,
319-
) from err
320-
if 400 <= err.status <= 499:
321-
# Non-recoverable error
322-
raise OAuth2TokenRequestReauthError(
323-
request_info=err.request_info,
324-
history=err.history,
325-
status=err.status,
326-
message=err.message,
327-
headers=err.headers,
328-
domain=self._domain,
329-
) from err
330-
331-
raise OAuth2TokenRequestError(
332-
request_info=err.request_info,
333-
history=err.history,
334-
status=err.status,
335-
message=err.message,
336-
headers=err.headers,
337-
domain=self._domain,
338-
) from err
339-
340-
return cast(dict, await resp.json())
358+
_raise_mapped_token_error(err, self.service_domain)
359+
except ClientError as err:
360+
# Bare TimeoutError is left alone so an enclosing asyncio.timeout still
361+
# aborts with oauth_timeout; aiohttp's own timeouts are ClientErrors.
362+
_raise_mapped_token_error(err, self.service_domain)
341363

342364

343365
class LocalOAuth2ImplementationWithPkce(LocalOAuth2Implementation):
@@ -844,6 +866,15 @@ async def async_ensure_token_valid(self) -> None:
844866
self.config_entry.async_start_reauth_if_available(self.hass)
845867
raise
846868

869+
# Checked before storing, so reads can trust what is on the entry.
870+
if any(
871+
new_token.get(field) in (None, "")
872+
for field in ("access_token", "expires_at")
873+
):
874+
raise OAuth2TokenRequestConnectionError(
875+
domain=self.implementation.service_domain
876+
)
877+
847878
self.hass.config_entries.async_update_entry(
848879
self.config_entry, data={**self.config_entry.data, "token": new_token}
849880
)

script/hassfest/quality_scale_validation/test_before_setup.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
"ConfigEntryError",
1515
}
1616

17+
# Helpers that raise one of the above on the caller's behalf, so an integration
18+
# awaiting them satisfies the rule without repeating the mapping itself.
19+
_VALID_AWAITED_CALLS = {
20+
"async_config_entry_first_refresh",
21+
"async_ensure_token_valid",
22+
}
23+
1724

1825
def _get_exception_name(expression: ast.expr) -> str:
1926
"""Get the name of the exception being raised."""
@@ -58,17 +65,20 @@ def _raises_exception(integration: Integration) -> bool:
5865
return False
5966

6067

61-
def _calls_first_refresh(async_setup_entry_function: ast.AsyncFunctionDef) -> bool:
62-
"""Check that a async_config_entry_first_refresh within `async_setup_entry`."""
63-
for node in ast.walk(async_setup_entry_function):
64-
if (
65-
isinstance(node, ast.Call)
66-
and isinstance(node.func, ast.Attribute)
67-
and node.func.attr == "async_config_entry_first_refresh"
68-
):
69-
return True
68+
def _awaits_raising_helper(async_setup_entry_function: ast.AsyncFunctionDef) -> bool:
69+
"""Check that `async_setup_entry` awaits a helper that raises on its behalf.
7070
71-
return False
71+
The call only has to sit somewhere inside an await, so gathering several of
72+
them still counts, while an unawaited call does not.
73+
"""
74+
return any(
75+
isinstance(node, ast.Call)
76+
and isinstance(node.func, ast.Attribute)
77+
and node.func.attr in _VALID_AWAITED_CALLS
78+
for await_node in ast.walk(async_setup_entry_function)
79+
if isinstance(await_node, ast.Await)
80+
for node in ast.walk(await_node)
81+
)
7282

7383

7484
def _get_setup_entry_function(module: ast.Module) -> ast.AsyncFunctionDef | None:
@@ -90,6 +100,8 @@ def validate(
90100
if not (async_setup_entry := _get_setup_entry_function(init)):
91101
return [f"Could not find `async_setup_entry` in {init_file}"]
92102

93-
if not (_calls_first_refresh(async_setup_entry) or _raises_exception(integration)):
103+
if not (
104+
_awaits_raising_helper(async_setup_entry) or _raises_exception(integration)
105+
):
94106
return [f"Integration does not raise one of {_VALID_EXCEPTIONS}"]
95107
return None

tests/components/cloud/test_account_link.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from time import time
77
from unittest.mock import AsyncMock, Mock, patch
88

9-
from aiohttp import ClientResponseError, RequestInfo
9+
from aiohttp import ClientError, ClientResponseError, RequestInfo
1010
import pytest
1111
from yarl import URL
1212

@@ -16,6 +16,7 @@
1616
from homeassistant.core import HomeAssistant
1717
from homeassistant.data_entry_flow import FlowResultType
1818
from homeassistant.exceptions import (
19+
OAuth2TokenRequestConnectionError,
1920
OAuth2TokenRequestError,
2021
OAuth2TokenRequestReauthError,
2122
OAuth2TokenRequestTransientError,
@@ -304,3 +305,24 @@ async def test_refresh_token_error(
304305

305306
assert exc_info.value.status == status
306307
assert exc_info.value.domain == "test"
308+
309+
310+
async def test_refresh_token_connection_error(hass: HomeAssistant) -> None:
311+
"""Test a failure without a response reports the service, not the cloud domain."""
312+
hass.data[DATA_CLOUD] = None
313+
impl = account_link.CloudOAuth2Implementation(hass, "test")
314+
315+
with (
316+
patch(
317+
"hass_nabucasa.account_link.async_fetch_access_token",
318+
side_effect=ClientError("Cannot connect"),
319+
),
320+
pytest.raises(OAuth2TokenRequestConnectionError) as exc_info,
321+
):
322+
await impl.async_refresh_token(
323+
{"refresh_token": "mock-refresh", "access_token": "mock-access"}
324+
)
325+
326+
assert impl.domain == "cloud"
327+
assert exc_info.value.domain == "test"
328+
assert exc_info.value.translation_placeholders == {"domain": "test"}

0 commit comments

Comments
 (0)