diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index bb522291d19..e6ea2819eb4 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -1,9 +1,11 @@ """The Netatmo integration.""" from __future__ import annotations +from http import HTTPStatus import logging import secrets +import aiohttp import pyatmo import voluptuous as vol @@ -21,6 +23,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import CoreState, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, @@ -45,6 +48,7 @@ from .const import ( DATA_PERSONS, DATA_SCHEDULES, DOMAIN, + NETATMO_SCOPES, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, PLATFORMS, @@ -112,6 +116,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, unique_id=DOMAIN) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as ex: + _LOGGER.debug("API error: %s (%s)", ex.code, ex.message) + if ex.code in ( + HTTPStatus.BAD_REQUEST, + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + raise ConfigEntryNotReady from ex + + if sorted(session.token["scope"]) != sorted(NETATMO_SCOPES): + _LOGGER.debug( + "Scope is invalid: %s != %s", session.token["scope"], NETATMO_SCOPES + ) + raise ConfigEntryAuthFailed("Token scope not valid, trigger renewal") + hass.data[DOMAIN][entry.entry_id] = { AUTH: api.AsyncConfigEntryNetatmoAuth( aiohttp_client.async_get_clientsession(hass), session @@ -224,15 +246,17 @@ async def async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + data = hass.data[DOMAIN] + if CONF_WEBHOOK_ID in entry.data: webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - await hass.data[DOMAIN][entry.entry_id][AUTH].async_dropwebhook() + await data[entry.entry_id][AUTH].async_dropwebhook() _LOGGER.info("Unregister Netatmo webhook") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + if unload_ok and entry.entry_id in data: + data.pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index bb6a034b19f..ad8f75f5d45 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -23,8 +23,11 @@ from .const import ( CONF_UUID, CONF_WEATHER_AREAS, DOMAIN, + NETATMO_SCOPES, ) +_LOGGER = logging.getLogger(__name__) + class NetatmoFlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN @@ -49,31 +52,46 @@ class NetatmoFlowHandler( @property def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" - scopes = [ - "access_camera", - "access_presence", - "read_camera", - "read_homecoach", - "read_presence", - "read_smokedetector", - "read_station", - "read_thermostat", - "write_camera", - "write_presence", - "write_thermostat", - ] - - return {"scope": " ".join(scopes)} + return {"scope": " ".join(NETATMO_SCOPES)} async def async_step_user(self, user_input: dict | None = None) -> FlowResult: """Handle a flow start.""" await self.async_set_unique_id(DOMAIN) - if self._async_current_entries(): + if ( + self.source != config_entries.SOURCE_REAUTH + and self._async_current_entries() + ): return self.async_abort(reason="single_instance_allowed") return await super().async_step_user(user_input) + async def async_step_reauth(self, user_input: dict | None = None) -> FlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({}), + ) + + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict) -> FlowResult: + """Create an oauth config entry or update existing entry for reauth.""" + existing_entry = await self.async_set_unique_id(DOMAIN) + if existing_entry: + self.hass.config_entries.async_update_entry(existing_entry, data=data) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return await super().async_oauth_create_entry(data) + class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): """Handle Netatmo options.""" diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 07651d982a5..14e165b5cb4 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -13,6 +13,20 @@ DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}" PLATFORMS = [CAMERA_DOMAIN, CLIMATE_DOMAIN, LIGHT_DOMAIN, SELECT_DOMAIN, SENSOR_DOMAIN] +NETATMO_SCOPES = [ + "access_camera", + "access_presence", + "read_camera", + "read_homecoach", + "read_presence", + "read_smokedetector", + "read_station", + "read_thermostat", + "write_camera", + "write_presence", + "write_thermostat", +] + MODEL_NAPLUG = "Relay" MODEL_NATHERM1 = "Smart Thermostat" MODEL_NRV = "Smart Radiator Valves" diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 9a03a3fa848..9d83aa02977 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -33,12 +33,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Netatmo camera light platform.""" - if "access_camera" not in entry.data["token"]["scope"]: - _LOGGER.info( - "Cameras are currently not supported with this authentication method" - ) - return - data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] await data_handler.register_data_class( diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index c65001b2e8f..f58daadcf7f 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -3,13 +3,18 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Netatmo integration needs to re-authenticate your account" } }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -62,4 +67,4 @@ "therm_mode": "{entity_name} switched to \"{subtype}\"" } } -} \ No newline at end of file +} diff --git a/homeassistant/components/netatmo/translations/en.json b/homeassistant/components/netatmo/translations/en.json index 7e230374720..5d089120697 100644 --- a/homeassistant/components/netatmo/translations/en.json +++ b/homeassistant/components/netatmo/translations/en.json @@ -1,18 +1,23 @@ { "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Netatmo integration needs to re-authenticate your account" + } + }, "abort": { - "authorize_url_timeout": "Timeout generating authorize URL.", - "missing_configuration": "The component is not configured. Please follow the documentation.", - "no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", - "single_instance_allowed": "Already configured. Only a single configuration possible." + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "create_entry": { - "default": "Successfully authenticated" - }, - "step": { - "pick_implementation": { - "title": "Pick Authentication Method" - } + "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, "device_automation": { diff --git a/tests/components/netatmo/conftest.py b/tests/components/netatmo/conftest.py index 4d6bbb752f3..808e477e053 100644 --- a/tests/components/netatmo/conftest.py +++ b/tests/components/netatmo/conftest.py @@ -22,7 +22,7 @@ def mock_config_entry_fixture(hass): "type": "Bearer", "expires_in": 60, "expires_at": time() + 1000, - "scope": " ".join(ALL_SCOPES), + "scope": ALL_SCOPES, }, }, options={ @@ -53,7 +53,7 @@ def mock_config_entry_fixture(hass): return mock_entry -@pytest.fixture +@pytest.fixture(name="netatmo_auth") def netatmo_auth(): """Restrict loaded platforms to list given.""" with patch( diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 8f18ae1410a..fbc1c62c0b1 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -13,6 +13,8 @@ from homeassistant.components.netatmo.const import ( from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers import config_entry_oauth2_flow +from .common import ALL_SCOPES + from tests.common import MockConfigEntry CLIENT_ID = "1234" @@ -67,21 +69,7 @@ async def test_full_flow( }, ) - scope = "+".join( - [ - "access_camera", - "access_presence", - "read_camera", - "read_homecoach", - "read_presence", - "read_smokedetector", - "read_station", - "read_thermostat", - "write_camera", - "write_presence", - "write_thermostat", - ] - ) + scope = "+".join(sorted(ALL_SCOPES)) assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" @@ -227,3 +215,110 @@ async def test_option_flow_wrong_coordinates(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY for k, v in expected_result.items(): assert config_entry.options[CONF_WEATHER_AREAS]["Home"][k] == v + + +async def test_reauth( + hass, hass_client_no_auth, aioclient_mock, current_request_with_host +): + """Test initialization of the reauth flow.""" + assert await setup.async_setup_component( + hass, + "netatmo", + { + "netatmo": {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET}, + "http": {"base_url": "https://example.com"}, + }, + ) + + result = await hass.config_entries.flow.async_init( + "netatmo", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + scope = "+".join(sorted(ALL_SCOPES)) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.netatmo.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + new_entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert new_entry.state == config_entries.ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + # Should show form + result = await hass.config_entries.flow.async_init( + "netatmo", context={"source": config_entries.SOURCE_REAUTH} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + # Confirm reauth flow + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + # Update entry + with patch( + "homeassistant.components.netatmo.async_setup_entry", return_value=True + ) as mock_setup: + result3 = await hass.config_entries.flow.async_configure(result2["flow_id"]) + await hass.async_block_till_done() + + new_entry2 = hass.config_entries.async_entries(DOMAIN)[0] + + assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result3["reason"] == "reauth_successful" + assert new_entry2.state == config_entries.ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index fba85d9d45c..2d0c43ac3f6 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -4,6 +4,7 @@ from datetime import timedelta from time import time from unittest.mock import AsyncMock, patch +import aiohttp import pyatmo from homeassistant import config_entries @@ -14,6 +15,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt from .common import ( + ALL_SCOPES, FAKE_WEBHOOK_ACTIVATION, fake_post_request, selected_platforms, @@ -49,24 +51,8 @@ FAKE_WEBHOOK = { } -async def test_setup_component(hass): +async def test_setup_component(hass, config_entry): """Test setup and teardown of the netatmo component.""" - config_entry = MockConfigEntry( - domain="netatmo", - data={ - "auth_implementation": "cloud", - "token": { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - "expires_at": time() + 1000, - "scope": "read_station", - }, - }, - ) - config_entry.add_to_hass(hass) - with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, patch( @@ -248,7 +234,7 @@ async def test_setup_with_cloudhook(hass): "type": "Bearer", "expires_in": 60, "expires_at": time() + 1000, - "scope": "read_station", + "scope": ALL_SCOPES, }, }, ) @@ -298,24 +284,8 @@ async def test_setup_with_cloudhook(hass): assert not hass.config_entries.async_entries(DOMAIN) -async def test_setup_component_api_error(hass): +async def test_setup_component_api_error(hass, config_entry): """Test error on setup of the netatmo component.""" - config_entry = MockConfigEntry( - domain="netatmo", - data={ - "auth_implementation": "cloud", - "token": { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - "expires_at": time() + 1000, - "scope": "read_station", - }, - }, - ) - config_entry.add_to_hass(hass) - with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, patch( @@ -337,24 +307,8 @@ async def test_setup_component_api_error(hass): mock_impl.assert_called_once() -async def test_setup_component_api_timeout(hass): +async def test_setup_component_api_timeout(hass, config_entry): """Test timeout on setup of the netatmo component.""" - config_entry = MockConfigEntry( - domain="netatmo", - data={ - "auth_implementation": "cloud", - "token": { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - "expires_at": time() + 1000, - "scope": "read_station", - }, - }, - ) - config_entry.add_to_hass(hass) - with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, patch( @@ -429,3 +383,101 @@ async def test_setup_component_with_delay(hass, config_entry): await hass.async_stop() mock_dropwebhook.assert_called_once() + + +async def test_setup_component_invalid_token_scope(hass): + """Test handling of invalid token scope.""" + config_entry = MockConfigEntry( + domain="netatmo", + data={ + "auth_implementation": "cloud", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 1000, + "scope": " ".join( + [ + "read_smokedetector", + "read_thermostat", + "write_thermostat", + ] + ), + }, + }, + options={}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", + ) as mock_auth, patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ) as mock_impl, patch( + "homeassistant.components.webhook.async_generate_url" + ) as mock_webhook: + mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + assert await async_setup_component(hass, "netatmo", {}) + + await hass.async_block_till_done() + + mock_auth.assert_not_called() + mock_impl.assert_called_once() + mock_webhook.assert_not_called() + + assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR + assert hass.config_entries.async_entries(DOMAIN) + assert len(hass.states.async_all()) > 0 + + for config_entry in hass.config_entries.async_entries("netatmo"): + await hass.config_entries.async_remove(config_entry.entry_id) + + +async def test_setup_component_invalid_token(hass, config_entry): + """Test handling of invalid token.""" + + async def fake_ensure_valid_token(*args, **kwargs): + print("fake_ensure_valid_token") + raise aiohttp.ClientResponseError( + request_info=aiohttp.client.RequestInfo( + url="http://example.com", + method="GET", + headers={}, + real_url="http://example.com", + ), + code=400, + history=(), + ) + + with patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", + ) as mock_auth, patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ) as mock_impl, patch( + "homeassistant.components.webhook.async_generate_url" + ) as mock_webhook, patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" + ) as mock_session: + mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + mock_session.return_value.async_ensure_token_valid.side_effect = ( + fake_ensure_valid_token + ) + assert await async_setup_component(hass, "netatmo", {}) + + await hass.async_block_till_done() + + mock_auth.assert_not_called() + mock_impl.assert_called_once() + mock_webhook.assert_not_called() + + assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR + assert hass.config_entries.async_entries(DOMAIN) + assert len(hass.states.async_all()) > 0 + + for config_entry in hass.config_entries.async_entries("netatmo"): + await hass.config_entries.async_remove(config_entry.entry_id)