|
17 | 17 | import logging |
18 | 18 | import secrets |
19 | 19 | import time |
20 | | -from typing import Any, cast, override |
| 20 | +from typing import Any, NoReturn, cast, override |
21 | 21 |
|
22 | 22 | from aiohttp import ClientError, ClientResponseError, client, hdrs, web |
23 | 23 | from habluetooth import BluetoothServiceInfoBleak |
|
30 | 30 | from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback |
31 | 31 | from homeassistant.exceptions import ( |
32 | 32 | ImplementationUnavailableError, |
| 33 | + OAuth2TokenRequestConnectionError, |
33 | 34 | OAuth2TokenRequestError, |
34 | 35 | OAuth2TokenRequestReauthError, |
35 | 36 | OAuth2TokenRequestTransientError, |
|
105 | 106 | ) |
106 | 107 |
|
107 | 108 |
|
| 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 | + |
108 | 131 | @callback |
109 | 132 | def async_get_redirect_uri(hass: HomeAssistant) -> str: |
110 | 133 | """Return the redirect uri.""" |
@@ -163,11 +186,30 @@ async def async_resolve_external_data(self, external_data: Any) -> dict: |
163 | 186 | config entry data. |
164 | 187 | """ |
165 | 188 |
|
| 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 | + |
166 | 198 | async def async_refresh_token(self, token: dict) -> dict: |
167 | 199 | """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) |
169 | 208 | # 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 |
171 | 213 | new_token["expires_at"] = time.time() + new_token["expires_in"] |
172 | 214 | return new_token |
173 | 215 |
|
@@ -268,6 +310,11 @@ async def _async_refresh_token(self, token: dict) -> dict: |
268 | 310 | } |
269 | 311 | ) |
270 | 312 |
|
| 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 | + |
271 | 318 | return {**token, **new_token} |
272 | 319 |
|
273 | 320 | async def _token_request(self, data: dict) -> dict: |
@@ -306,38 +353,13 @@ async def _token_request(self, data: dict) -> dict: |
306 | 353 | detail, |
307 | 354 | ) |
308 | 355 | resp.raise_for_status() |
| 356 | + return cast(dict, await resp.json()) |
309 | 357 | 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) |
341 | 363 |
|
342 | 364 |
|
343 | 365 | class LocalOAuth2ImplementationWithPkce(LocalOAuth2Implementation): |
@@ -844,6 +866,15 @@ async def async_ensure_token_valid(self) -> None: |
844 | 866 | self.config_entry.async_start_reauth_if_available(self.hass) |
845 | 867 | raise |
846 | 868 |
|
| 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 | + |
847 | 878 | self.hass.config_entries.async_update_entry( |
848 | 879 | self.config_entry, data={**self.config_entry.data, "token": new_token} |
849 | 880 | ) |
|
0 commit comments