diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index bcc752ff173..2914851ccbf 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -10,12 +10,16 @@ import logging from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from pysmartapp.event import EVENT_TYPE_DEVICE -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -106,7 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # to import the modules. await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) - remove_entry = False try: # See if the app is already setup. This occurs when there are # installs in multiple SmartThings locations (valid use-case) @@ -175,34 +178,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: broker.connect() hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker + except APIInvalidGrant as ex: + raise ConfigEntryAuthFailed from ex except ClientResponseError as ex: if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - _LOGGER.exception( - ( - "Unable to setup configuration entry '%s' - please reconfigure the" - " integration" - ), - entry.title, - ) - remove_entry = True - else: - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex + raise ConfigEntryError( + "The access token is no longer valid. Please remove the integration and set up again." + ) from ex + _LOGGER.debug(ex, exc_info=True) + raise ConfigEntryNotReady from ex except (ClientConnectionError, RuntimeWarning) as ex: _LOGGER.debug(ex, exc_info=True) raise ConfigEntryNotReady from ex - if remove_entry: - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - # only create new flow if there isn't a pending one for SmartThings. - if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - return False - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 081f833787e..7b49854740a 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure SmartThings.""" +from collections.abc import Mapping from http import HTTPStatus import logging from typing import Any @@ -9,7 +10,7 @@ from pysmartthings import APIResponseError, AppOAuth, SmartThings from pysmartthings.installedapp import format_install_url import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -213,7 +214,10 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): url = format_install_url(self.app_id, self.location_id) return self.async_external_step(step_id="authorize", url=url) - return self.async_external_step_done(next_step_id="install") + next_step_id = "install" + if self.source == SOURCE_REAUTH: + next_step_id = "update" + return self.async_external_step_done(next_step_id=next_step_id) def _show_step_pat(self, errors): if self.access_token is None: @@ -240,6 +244,41 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + self.app_id = self._get_reauth_entry().data[CONF_APP_ID] + self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID] + self._set_confirm_only() + return await self.async_step_authorize() + + async def async_step_update( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_update_confirm() + + async def async_step_update_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + self._set_confirm_only() + return self.async_show_form(step_id="update_confirm") + entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token} + ) + async def async_step_install( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 6b0da00b132..76b6804075f 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -1,5 +1,7 @@ """SmartApp functionality to receive cloud-push notifications.""" +from __future__ import annotations + import asyncio import functools import logging @@ -27,6 +29,7 @@ from pysmartthings import ( ) from homeassistant.components import cloud, webhook +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -400,7 +403,7 @@ async def smartapp_sync_subscriptions( _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) -async def _continue_flow( +async def _find_and_continue_flow( hass: HomeAssistant, app_id: str, location_id: str, @@ -418,24 +421,34 @@ async def _continue_flow( None, ) if flow is not None: - await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_REFRESH_TOKEN: refresh_token, - }, - ) - _LOGGER.debug( - "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - installed_app_id, - app_id, - ) + await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow) + + +async def _continue_flow( + hass: HomeAssistant, + app_id: str, + installed_app_id: str, + refresh_token: str, + flow: ConfigFlowResult, +) -> None: + await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_REFRESH_TOKEN: refresh_token, + }, + ) + _LOGGER.debug( + "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + installed_app_id, + app_id, + ) async def smartapp_install(hass: HomeAssistant, req, resp, app): """Handle a SmartApp installation and continue the config flow.""" - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( @@ -447,6 +460,27 @@ async def smartapp_install(hass: HomeAssistant, req, resp, app): async def smartapp_update(hass: HomeAssistant, req, resp, app): """Handle a SmartApp update and either update the entry or continue the flow.""" + unique_id = format_unique_id(app.app_id, req.location_id) + flow = next( + ( + flow + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if flow["context"].get("unique_id") == unique_id + and flow["step_id"] == "authorize" + ), + None, + ) + if flow is not None: + await _continue_flow( + hass, app.app_id, req.installed_app_id, req.refresh_token, flow + ) + _LOGGER.debug( + "Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + req.installed_app_id, + app.app_id, + ) + return entry = next( ( entry @@ -466,7 +500,7 @@ async def smartapp_update(hass: HomeAssistant, req, resp, app): app.app_id, ) - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index de94e5adfcd..31a552be149 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -7,7 +7,7 @@ }, "pat": { "title": "Enter Personal Access Token", - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.", + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**", "data": { "access_token": "[%key:common::config_flow::data::access_token%]" } @@ -17,11 +17,20 @@ "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", "data": { "location_id": "[%key:common::config_flow::data::location%]" } }, - "authorize": { "title": "Authorize Home Assistant" } + "authorize": { "title": "Authorize Home Assistant" }, + "reauth_confirm": { + "title": "Reauthorize Home Assistant", + "description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again." + }, + "update_confirm": { + "title": "Finish reauthentication", + "description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process." + } }, "abort": { "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", - "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant." + "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.", + "reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings." }, "error": { "token_invalid_format": "The token must be in the UID/GUID format", diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 3621e58bc3d..05ddc3a71de 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -14,6 +14,7 @@ from homeassistant.components.smartthings.const import ( CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, + CONF_REFRESH_TOKEN, DOMAIN, ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET @@ -757,3 +758,56 @@ async def test_no_available_locations_aborts( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_available_locations" + + +async def test_reauth( + hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock +) -> None: + """Test reauth flow.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_APP_ID: app.app_id, + CONF_CLIENT_ID: app_oauth_client.client_id, + CONF_CLIENT_SECRET: app_oauth_client.client_secret, + CONF_LOCATION_ID: location.location_id, + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_ACCESS_TOKEN: token, + CONF_REFRESH_TOKEN: "abc", + }, + unique_id=smartapp.format_unique_id(app.app_id, location.location_id), + ) + entry.add_to_hass(hass) + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + await smartapp.smartapp_update(hass, request, None, app) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "update_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert entry.data[CONF_REFRESH_TOKEN] == refresh_token diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index e518f84aecb..83372b58228 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -23,6 +23,7 @@ from homeassistant.components.smartthings.const import ( PLATFORMS, SIGNAL_SMARTTHINGS_UPDATE, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.exceptions import ConfigEntryNotReady @@ -68,17 +69,10 @@ async def test_unrecoverable_api_errors_create_new_flow( ) # Assert setup returns false - result = await smartthings.async_setup_entry(hass, config_entry) + result = await hass.config_entries.async_setup(config_entry.entry_id) assert not result - # Assert entry was removed and new flow created - await hass.async_block_till_done() - assert not hass.config_entries.async_entries(DOMAIN) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["handler"] == "smartthings" - assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - hass.config_entries.flow.async_abort(flows[0]["flow_id"]) + assert config_entry.state == ConfigEntryState.SETUP_ERROR async def test_recoverable_api_errors_raise_not_ready(