mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 15:31:52 -05:00
Continue central error handling for OAuth (#180588)
This commit is contained in:
@@ -113,6 +113,12 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
|
||||
"""Domain that is providing the implementation."""
|
||||
return DOMAIN
|
||||
|
||||
@property
|
||||
@override
|
||||
def service_domain(self) -> str:
|
||||
"""Domain of the service the tokens are for."""
|
||||
return self.service
|
||||
|
||||
@override
|
||||
async def async_generate_authorize_url(self, flow_id: str) -> str:
|
||||
"""Generate a url for the user to authorize."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from collections.abc import Callable, Generator, Sequence
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from aiohttp import ClientResponse, ClientResponseError, RequestInfo
|
||||
from aiohttp import ClientError, ClientResponse, ClientResponseError, RequestInfo
|
||||
from multidict import MultiMapping
|
||||
|
||||
from .util.event_type import EventType
|
||||
@@ -253,8 +253,20 @@ class ConfigEntryAuthFailed(IntegrationError):
|
||||
"""Error to indicate that config entry could not authenticate."""
|
||||
|
||||
|
||||
class OAuth2TokenRequestError(ClientResponseError, HomeAssistantError):
|
||||
"""Error to indicate that the OAuth 2.0 flow could not refresh token."""
|
||||
class OAuth2TokenRequestBaseError(ConfigEntryNotReady):
|
||||
"""Base class for the errors a failed OAuth 2.0 token request raises.
|
||||
|
||||
Catch this to handle every token request failure; the subclasses differ in
|
||||
whether a status was received and what should happen to the config entry.
|
||||
"""
|
||||
|
||||
|
||||
class OAuth2TokenRequestError(ClientResponseError, OAuth2TokenRequestBaseError):
|
||||
"""Error to indicate that the OAuth 2.0 flow could not refresh token.
|
||||
|
||||
Inherits ConfigEntryNotReady so setup retries without the integration having to
|
||||
map it. Catch it explicitly to handle it differently.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -275,7 +287,7 @@ class OAuth2TokenRequestError(ClientResponseError, HomeAssistantError):
|
||||
message=message,
|
||||
headers=headers,
|
||||
)
|
||||
HomeAssistantError.__init__(self)
|
||||
OAuth2TokenRequestBaseError.__init__(self)
|
||||
self.domain = domain
|
||||
self.translation_domain = "homeassistant"
|
||||
self.translation_key = "oauth2_helper_refresh_failed"
|
||||
@@ -283,7 +295,24 @@ class OAuth2TokenRequestError(ClientResponseError, HomeAssistantError):
|
||||
self.generate_message = True
|
||||
|
||||
|
||||
class OAuth2TokenRequestTransientError(OAuth2TokenRequestError, ConfigEntryNotReady):
|
||||
class OAuth2TokenRequestConnectionError(ClientError, OAuth2TokenRequestBaseError):
|
||||
"""Recoverable error to indicate the token request yielded no usable token.
|
||||
|
||||
Covers a request that never got a response and one whose response could not
|
||||
be used, neither of which has a status to tell the causes apart.
|
||||
"""
|
||||
|
||||
def __init__(self, *, domain: str) -> None:
|
||||
"""Initialize OAuth2TokenRequestConnectionError."""
|
||||
OAuth2TokenRequestBaseError.__init__(self)
|
||||
self.domain = domain
|
||||
self.translation_domain = "homeassistant"
|
||||
self.translation_key = "oauth2_helper_refresh_transient"
|
||||
self.translation_placeholders = {"domain": domain}
|
||||
self.generate_message = True
|
||||
|
||||
|
||||
class OAuth2TokenRequestTransientError(OAuth2TokenRequestError):
|
||||
"""Recoverable error to indicate flow could not refresh token.
|
||||
|
||||
Inherits ConfigEntryNotReady so setup retries without the integration having to
|
||||
|
||||
@@ -17,7 +17,7 @@ import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, cast, override
|
||||
from typing import Any, NoReturn, cast, override
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError, client, hdrs, web
|
||||
from habluetooth import BluetoothServiceInfoBleak
|
||||
@@ -30,6 +30,7 @@ from homeassistant import config_entries
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.exceptions import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2TokenRequestConnectionError,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
OAuth2TokenRequestTransientError,
|
||||
@@ -105,6 +106,28 @@ _SHARED_ABORT_REASONS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def _raise_mapped_token_error(err: ClientError, domain: str) -> NoReturn:
|
||||
"""Re-raise a failed token request as the matching OAuth2 token error."""
|
||||
if not isinstance(err, ClientResponseError):
|
||||
# Nothing was received, so there is no status to tell the causes apart.
|
||||
_LOGGER.debug("Token request for %s got no response: %s", domain, err)
|
||||
raise OAuth2TokenRequestConnectionError(domain=domain) from err
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"request_info": err.request_info,
|
||||
"history": err.history,
|
||||
"status": err.status,
|
||||
"message": err.message,
|
||||
"headers": err.headers,
|
||||
"domain": domain,
|
||||
}
|
||||
if err.status == HTTPStatus.TOO_MANY_REQUESTS or 500 <= err.status <= 599:
|
||||
raise OAuth2TokenRequestTransientError(**kwargs) from err
|
||||
if 400 <= err.status <= 499:
|
||||
raise OAuth2TokenRequestReauthError(**kwargs) from err
|
||||
raise OAuth2TokenRequestError(**kwargs) from err
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_redirect_uri(hass: HomeAssistant) -> str:
|
||||
"""Return the redirect uri."""
|
||||
@@ -163,11 +186,30 @@ class AbstractOAuth2Implementation(ABC):
|
||||
config entry data.
|
||||
"""
|
||||
|
||||
@property
|
||||
def service_domain(self) -> str:
|
||||
"""Domain of the service the tokens are for.
|
||||
|
||||
Defaults to the implementation itself, but an implementation that obtains
|
||||
tokens on behalf of other integrations has to name the one it serves.
|
||||
"""
|
||||
return self.domain
|
||||
|
||||
async def async_refresh_token(self, token: dict) -> dict:
|
||||
"""Refresh a token and update expires info."""
|
||||
new_token = await self._async_refresh_token(token)
|
||||
try:
|
||||
new_token = await self._async_refresh_token(token)
|
||||
except OAuth2TokenRequestError, OAuth2TokenRequestConnectionError:
|
||||
raise
|
||||
except ClientError as err:
|
||||
# Implementations that issue their own token request may not map their
|
||||
# failures, so callers would see a raw aiohttp error instead.
|
||||
_raise_mapped_token_error(err, self.service_domain)
|
||||
# Force int for non-compliant oauth2 providers
|
||||
new_token["expires_in"] = int(new_token["expires_in"])
|
||||
try:
|
||||
new_token["expires_in"] = int(new_token["expires_in"])
|
||||
except (KeyError, TypeError, ValueError) as err:
|
||||
raise OAuth2TokenRequestConnectionError(domain=self.service_domain) from err
|
||||
new_token["expires_at"] = time.time() + new_token["expires_in"]
|
||||
return new_token
|
||||
|
||||
@@ -268,6 +310,11 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
|
||||
}
|
||||
)
|
||||
|
||||
# Merging a response without one would keep the stale access token while
|
||||
# extending its expiry, so the session would never recover.
|
||||
if not new_token.get("access_token"):
|
||||
raise OAuth2TokenRequestConnectionError(domain=self.service_domain)
|
||||
|
||||
return {**token, **new_token}
|
||||
|
||||
async def _token_request(self, data: dict) -> dict:
|
||||
@@ -306,38 +353,13 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
|
||||
detail,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return cast(dict, await resp.json())
|
||||
except ClientResponseError as err:
|
||||
if err.status == HTTPStatus.TOO_MANY_REQUESTS or 500 <= err.status <= 599:
|
||||
# Recoverable error
|
||||
raise OAuth2TokenRequestTransientError(
|
||||
request_info=err.request_info,
|
||||
history=err.history,
|
||||
status=err.status,
|
||||
message=err.message,
|
||||
headers=err.headers,
|
||||
domain=self._domain,
|
||||
) from err
|
||||
if 400 <= err.status <= 499:
|
||||
# Non-recoverable error
|
||||
raise OAuth2TokenRequestReauthError(
|
||||
request_info=err.request_info,
|
||||
history=err.history,
|
||||
status=err.status,
|
||||
message=err.message,
|
||||
headers=err.headers,
|
||||
domain=self._domain,
|
||||
) from err
|
||||
|
||||
raise OAuth2TokenRequestError(
|
||||
request_info=err.request_info,
|
||||
history=err.history,
|
||||
status=err.status,
|
||||
message=err.message,
|
||||
headers=err.headers,
|
||||
domain=self._domain,
|
||||
) from err
|
||||
|
||||
return cast(dict, await resp.json())
|
||||
_raise_mapped_token_error(err, self.service_domain)
|
||||
except ClientError as err:
|
||||
# Bare TimeoutError is left alone so an enclosing asyncio.timeout still
|
||||
# aborts with oauth_timeout; aiohttp's own timeouts are ClientErrors.
|
||||
_raise_mapped_token_error(err, self.service_domain)
|
||||
|
||||
|
||||
class LocalOAuth2ImplementationWithPkce(LocalOAuth2Implementation):
|
||||
@@ -844,6 +866,15 @@ class OAuth2Session:
|
||||
self.config_entry.async_start_reauth_if_available(self.hass)
|
||||
raise
|
||||
|
||||
# Checked before storing, so reads can trust what is on the entry.
|
||||
if any(
|
||||
new_token.get(field) in (None, "")
|
||||
for field in ("access_token", "expires_at")
|
||||
):
|
||||
raise OAuth2TokenRequestConnectionError(
|
||||
domain=self.implementation.service_domain
|
||||
)
|
||||
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data={**self.config_entry.data, "token": new_token}
|
||||
)
|
||||
|
||||
@@ -14,6 +14,13 @@ _VALID_EXCEPTIONS = {
|
||||
"ConfigEntryError",
|
||||
}
|
||||
|
||||
# Helpers that raise one of the above on the caller's behalf, so an integration
|
||||
# awaiting them satisfies the rule without repeating the mapping itself.
|
||||
_VALID_AWAITED_CALLS = {
|
||||
"async_config_entry_first_refresh",
|
||||
"async_ensure_token_valid",
|
||||
}
|
||||
|
||||
|
||||
def _get_exception_name(expression: ast.expr) -> str:
|
||||
"""Get the name of the exception being raised."""
|
||||
@@ -58,17 +65,20 @@ def _raises_exception(integration: Integration) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _calls_first_refresh(async_setup_entry_function: ast.AsyncFunctionDef) -> bool:
|
||||
"""Check that a async_config_entry_first_refresh within `async_setup_entry`."""
|
||||
for node in ast.walk(async_setup_entry_function):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "async_config_entry_first_refresh"
|
||||
):
|
||||
return True
|
||||
def _awaits_raising_helper(async_setup_entry_function: ast.AsyncFunctionDef) -> bool:
|
||||
"""Check that `async_setup_entry` awaits a helper that raises on its behalf.
|
||||
|
||||
return False
|
||||
The call only has to sit somewhere inside an await, so gathering several of
|
||||
them still counts, while an unawaited call does not.
|
||||
"""
|
||||
return any(
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr in _VALID_AWAITED_CALLS
|
||||
for await_node in ast.walk(async_setup_entry_function)
|
||||
if isinstance(await_node, ast.Await)
|
||||
for node in ast.walk(await_node)
|
||||
)
|
||||
|
||||
|
||||
def _get_setup_entry_function(module: ast.Module) -> ast.AsyncFunctionDef | None:
|
||||
@@ -90,6 +100,8 @@ def validate(
|
||||
if not (async_setup_entry := _get_setup_entry_function(init)):
|
||||
return [f"Could not find `async_setup_entry` in {init_file}"]
|
||||
|
||||
if not (_calls_first_refresh(async_setup_entry) or _raises_exception(integration)):
|
||||
if not (
|
||||
_awaits_raising_helper(async_setup_entry) or _raises_exception(integration)
|
||||
):
|
||||
return [f"Integration does not raise one of {_VALID_EXCEPTIONS}"]
|
||||
return None
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
from time import time
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from aiohttp import ClientResponseError, RequestInfo
|
||||
from aiohttp import ClientError, ClientResponseError, RequestInfo
|
||||
import pytest
|
||||
from yarl import URL
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.cloud.const import DATA_CLOUD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import (
|
||||
OAuth2TokenRequestConnectionError,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
OAuth2TokenRequestTransientError,
|
||||
@@ -304,3 +305,24 @@ async def test_refresh_token_error(
|
||||
|
||||
assert exc_info.value.status == status
|
||||
assert exc_info.value.domain == "test"
|
||||
|
||||
|
||||
async def test_refresh_token_connection_error(hass: HomeAssistant) -> None:
|
||||
"""Test a failure without a response reports the service, not the cloud domain."""
|
||||
hass.data[DATA_CLOUD] = None
|
||||
impl = account_link.CloudOAuth2Implementation(hass, "test")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"hass_nabucasa.account_link.async_fetch_access_token",
|
||||
side_effect=ClientError("Cannot connect"),
|
||||
),
|
||||
pytest.raises(OAuth2TokenRequestConnectionError) as exc_info,
|
||||
):
|
||||
await impl.async_refresh_token(
|
||||
{"refresh_token": "mock-refresh", "access_token": "mock-access"}
|
||||
)
|
||||
|
||||
assert impl.domain == "cloud"
|
||||
assert exc_info.value.domain == "test"
|
||||
assert exc_info.value.translation_placeholders == {"domain": "test"}
|
||||
|
||||
@@ -7,15 +7,24 @@ import time
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiohttp import ClientError
|
||||
from multidict import CIMultiDict
|
||||
from aiohttp import (
|
||||
ClientError,
|
||||
ClientPayloadError,
|
||||
ClientResponseError,
|
||||
ContentTypeError,
|
||||
RequestInfo,
|
||||
ServerTimeoutError,
|
||||
)
|
||||
from multidict import CIMultiDict, CIMultiDictProxy
|
||||
import pytest
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant import config_entries, data_entry_flow, setup
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestConnectionError,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
OAuth2TokenRequestTransientError,
|
||||
@@ -24,7 +33,7 @@ from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.network import NoURLAvailableError
|
||||
|
||||
from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
TEST_DOMAIN = "oauth2_test"
|
||||
@@ -35,6 +44,8 @@ ACCESS_TOKEN_1 = "mock-access-token-1"
|
||||
ACCESS_TOKEN_2 = "mock-access-token-2"
|
||||
AUTHORIZE_URL = "https://example.como/auth/authorize"
|
||||
TOKEN_URL = "https://example.como/auth/token"
|
||||
# Far enough ahead that a token carrying it always counts as unexpired.
|
||||
FUTURE_EXPIRES_AT = 2000000000
|
||||
MOCK_SECRET_TOKEN_URLSAFE = (
|
||||
"token-"
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
@@ -1106,6 +1117,356 @@ async def test_oauth_session_refresh_failure_exceptions(
|
||||
assert f"Token request for {TEST_DOMAIN} failed" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raised",
|
||||
[
|
||||
pytest.param(ClientError("Cannot connect"), id="client_error"),
|
||||
pytest.param(ServerTimeoutError("Timeout"), id="timeout"),
|
||||
],
|
||||
)
|
||||
async def test_oauth_session_refresh_connection_error_is_transient(
|
||||
hass: HomeAssistant,
|
||||
flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler],
|
||||
local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
raised: Exception,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test a token request that never gets a response is mapped to a transient error."""
|
||||
mock_integration(hass, MockModule(domain=TEST_DOMAIN))
|
||||
|
||||
flow_handler.async_register_implementation(hass, local_impl)
|
||||
|
||||
aioclient_mock.post(TOKEN_URL, exc=raised)
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=TEST_DOMAIN,
|
||||
data={
|
||||
"auth_implementation": TEST_DOMAIN,
|
||||
"token": {
|
||||
"refresh_token": REFRESH_TOKEN,
|
||||
"access_token": ACCESS_TOKEN_1,
|
||||
"expires_at": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl)
|
||||
with (
|
||||
caplog.at_level(logging.DEBUG),
|
||||
pytest.raises(OAuth2TokenRequestConnectionError) as err,
|
||||
):
|
||||
await session.async_ensure_token_valid()
|
||||
|
||||
# Integrations rely on this to retry setup without mapping the error themselves.
|
||||
assert isinstance(err.value, ConfigEntryNotReady)
|
||||
assert err.value.translation_domain == HOMEASSISTANT_DOMAIN
|
||||
assert err.value.translation_key == "oauth2_helper_refresh_transient"
|
||||
assert f"Token request for {TEST_DOMAIN} got no response" in caplog.text
|
||||
assert str(raised) in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response",
|
||||
[
|
||||
pytest.param({"access_token": ACCESS_TOKEN_2}, id="missing_expires_in"),
|
||||
pytest.param(
|
||||
{"access_token": ACCESS_TOKEN_2, "expires_in": "soon"},
|
||||
id="unparsable_expires_in",
|
||||
),
|
||||
pytest.param(
|
||||
{"access_token": ACCESS_TOKEN_2, "expires_in": None},
|
||||
id="null_expires_in",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_oauth_session_malformed_refresh_response_is_not_reauth(
|
||||
hass: HomeAssistant,
|
||||
local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
response: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test an unusable token response retries instead of blaming stored credentials."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=TEST_DOMAIN,
|
||||
data={
|
||||
"auth_implementation": TEST_DOMAIN,
|
||||
"token": {
|
||||
"access_token": ACCESS_TOKEN_1,
|
||||
"refresh_token": REFRESH_TOKEN,
|
||||
"expires_at": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
aioclient_mock.post(TOKEN_URL, json=response)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl)
|
||||
with (
|
||||
patch.object(config_entry, "async_start_reauth_if_available") as start_reauth,
|
||||
pytest.raises(OAuth2TokenRequestConnectionError) as err,
|
||||
):
|
||||
await session.async_ensure_token_valid()
|
||||
|
||||
assert isinstance(err.value, ConfigEntryNotReady)
|
||||
assert err.value.translation_domain == HOMEASSISTANT_DOMAIN
|
||||
assert err.value.translation_key == "oauth2_helper_refresh_transient"
|
||||
# Relinking the account cannot fix a bad response, so it must not ask for it.
|
||||
start_reauth.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response",
|
||||
[
|
||||
pytest.param({"expires_in": 100}, id="no_access_token"),
|
||||
pytest.param({"access_token": None, "expires_in": 100}, id="null_access_token"),
|
||||
pytest.param({"access_token": "", "expires_in": 100}, id="blank_access_token"),
|
||||
],
|
||||
)
|
||||
async def test_oauth_session_refresh_without_access_token_is_rejected(
|
||||
hass: HomeAssistant,
|
||||
local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
response: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test a response with no usable access token is not merged over the old one."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=TEST_DOMAIN,
|
||||
data={
|
||||
"auth_implementation": TEST_DOMAIN,
|
||||
"token": {
|
||||
"access_token": ACCESS_TOKEN_1,
|
||||
"refresh_token": REFRESH_TOKEN,
|
||||
"expires_at": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
aioclient_mock.post(TOKEN_URL, json=response)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl)
|
||||
with pytest.raises(OAuth2TokenRequestConnectionError):
|
||||
await session.async_ensure_token_valid()
|
||||
|
||||
# The stale token must stay expired so the next attempt refreshes again.
|
||||
assert config_entry.data["token"]["access_token"] == ACCESS_TOKEN_1
|
||||
assert config_entry.data["token"]["expires_at"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"refreshed",
|
||||
[
|
||||
pytest.param({"expires_in": 100}, id="no_access_token"),
|
||||
pytest.param({"access_token": None, "expires_in": 100}, id="null_access_token"),
|
||||
],
|
||||
)
|
||||
async def test_oauth_session_custom_implementation_without_access_token(
|
||||
hass: HomeAssistant,
|
||||
refreshed: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test an implementation returning no usable access token is rejected."""
|
||||
|
||||
class BadImplementation(MockOAuth2Implementation):
|
||||
"""Implementation whose refresh skips the local token request."""
|
||||
|
||||
async def _async_refresh_token(self, token: dict) -> dict:
|
||||
"""Refresh a token."""
|
||||
return refreshed
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=TEST_DOMAIN,
|
||||
data={
|
||||
"auth_implementation": TEST_DOMAIN,
|
||||
"token": {
|
||||
"access_token": ACCESS_TOKEN_1,
|
||||
"refresh_token": REFRESH_TOKEN,
|
||||
"expires_at": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(
|
||||
hass, config_entry, BadImplementation()
|
||||
)
|
||||
with pytest.raises(OAuth2TokenRequestConnectionError):
|
||||
await session.async_ensure_token_valid()
|
||||
|
||||
assert config_entry.data["token"]["access_token"] == ACCESS_TOKEN_1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"refreshed",
|
||||
[
|
||||
pytest.param({"expires_at": FUTURE_EXPIRES_AT}, id="no_access_token"),
|
||||
pytest.param({"access_token": ACCESS_TOKEN_2}, id="no_expires_at"),
|
||||
],
|
||||
)
|
||||
async def test_oauth_session_never_stores_an_unusable_token(
|
||||
hass: HomeAssistant,
|
||||
refreshed: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test the session checks the new token even when the refresh skips its own."""
|
||||
|
||||
class UncheckedImplementation(MockOAuth2Implementation):
|
||||
"""Implementation overriding the public refresh, so nothing maps for it."""
|
||||
|
||||
async def async_refresh_token(self, token: dict) -> dict:
|
||||
"""Refresh a token without the base class validation."""
|
||||
return refreshed
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=TEST_DOMAIN,
|
||||
data={
|
||||
"auth_implementation": TEST_DOMAIN,
|
||||
"token": {
|
||||
"access_token": ACCESS_TOKEN_1,
|
||||
"refresh_token": REFRESH_TOKEN,
|
||||
"expires_at": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(
|
||||
hass, config_entry, UncheckedImplementation()
|
||||
)
|
||||
with pytest.raises(OAuth2TokenRequestConnectionError):
|
||||
await session.async_ensure_token_valid()
|
||||
|
||||
assert config_entry.data["token"]["access_token"] == ACCESS_TOKEN_1
|
||||
assert config_entry.data["token"]["expires_at"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raised", "expected_exception"),
|
||||
[
|
||||
pytest.param(
|
||||
ClientPayloadError("Disconnected"),
|
||||
OAuth2TokenRequestConnectionError,
|
||||
id="payload_error",
|
||||
),
|
||||
pytest.param(
|
||||
ContentTypeError(
|
||||
RequestInfo(
|
||||
url=URL(TOKEN_URL),
|
||||
method="POST",
|
||||
headers=CIMultiDictProxy(CIMultiDict()),
|
||||
),
|
||||
(),
|
||||
),
|
||||
OAuth2TokenRequestError,
|
||||
id="content_type_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_oauth_session_refresh_body_error_is_mapped(
|
||||
hass: HomeAssistant,
|
||||
flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler],
|
||||
local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
raised: Exception,
|
||||
expected_exception: type[Exception],
|
||||
) -> None:
|
||||
"""Test a failure reading the token response body does not leak an aiohttp error."""
|
||||
mock_integration(hass, MockModule(domain=TEST_DOMAIN))
|
||||
|
||||
flow_handler.async_register_implementation(hass, local_impl)
|
||||
|
||||
aioclient_mock.post(TOKEN_URL, json={})
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=TEST_DOMAIN,
|
||||
data={
|
||||
"auth_implementation": TEST_DOMAIN,
|
||||
"token": {
|
||||
"refresh_token": REFRESH_TOKEN,
|
||||
"access_token": ACCESS_TOKEN_1,
|
||||
"expires_at": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl)
|
||||
with (
|
||||
patch.object(AiohttpClientMockResponse, "json", side_effect=raised),
|
||||
pytest.raises(expected_exception) as err,
|
||||
):
|
||||
await session.async_ensure_token_valid()
|
||||
|
||||
assert type(err.value) is expected_exception
|
||||
assert isinstance(err.value, ConfigEntryNotReady)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raised", "expected_exception", "expected_base"),
|
||||
[
|
||||
pytest.param(
|
||||
ClientResponseError(
|
||||
RequestInfo(
|
||||
url=URL(TOKEN_URL),
|
||||
method="POST",
|
||||
headers=CIMultiDictProxy(CIMultiDict()),
|
||||
),
|
||||
(),
|
||||
status=HTTPStatus.UNAUTHORIZED,
|
||||
),
|
||||
OAuth2TokenRequestReauthError,
|
||||
ConfigEntryAuthFailed,
|
||||
id="reauth",
|
||||
),
|
||||
pytest.param(
|
||||
ClientResponseError(
|
||||
RequestInfo(
|
||||
url=URL(TOKEN_URL),
|
||||
method="POST",
|
||||
headers=CIMultiDictProxy(CIMultiDict()),
|
||||
),
|
||||
(),
|
||||
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
),
|
||||
OAuth2TokenRequestTransientError,
|
||||
ConfigEntryNotReady,
|
||||
id="transient",
|
||||
),
|
||||
pytest.param(
|
||||
ClientError("Cannot connect"),
|
||||
OAuth2TokenRequestConnectionError,
|
||||
ConfigEntryNotReady,
|
||||
id="connection_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_refresh_maps_errors_from_custom_implementation(
|
||||
hass: HomeAssistant,
|
||||
raised: Exception,
|
||||
expected_exception: type[Exception],
|
||||
expected_base: type[Exception],
|
||||
) -> None:
|
||||
"""Test an implementation issuing its own token request still raises mapped errors."""
|
||||
|
||||
class UnmappedImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation):
|
||||
"""Implementation that lets raw aiohttp errors escape, like a custom one."""
|
||||
|
||||
async def _async_refresh_token(self, token: dict) -> dict:
|
||||
raise raised
|
||||
|
||||
implementation = UnmappedImplementation(
|
||||
hass, TEST_DOMAIN, CLIENT_ID, CLIENT_SECRET, AUTHORIZE_URL, TOKEN_URL
|
||||
)
|
||||
|
||||
with pytest.raises(expected_exception) as err:
|
||||
await implementation.async_refresh_token({"refresh_token": REFRESH_TOKEN})
|
||||
|
||||
assert type(err.value) is expected_exception
|
||||
assert isinstance(err.value, expected_base)
|
||||
assert err.value.__cause__ is raised
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry_state",
|
||||
[
|
||||
@@ -1534,8 +1895,8 @@ async def test_oauth2_request_replaces_caller_authorization_header(
|
||||
),
|
||||
pytest.param(
|
||||
600,
|
||||
config_entries.ConfigEntryState.SETUP_ERROR,
|
||||
None,
|
||||
config_entries.ConfigEntryState.SETUP_RETRY,
|
||||
"oauth2_helper_refresh_failed",
|
||||
id="generic",
|
||||
),
|
||||
],
|
||||
@@ -1551,8 +1912,8 @@ async def test_token_error_handled_without_integration_mapping(
|
||||
) -> None:
|
||||
"""Test setup maps token refresh errors when the integration does not.
|
||||
|
||||
Only the transient and reauth subclasses carry config entry semantics, the
|
||||
base error is left to the integration.
|
||||
Every subclass carries config entry semantics, so the reauth subclass fails
|
||||
setup while the others retry it.
|
||||
"""
|
||||
aioclient_mock.post(TOKEN_URL, status=status_code, json={})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user