Add reauthentication to SmartThings (#135673)

* Add reauthentication to SmartThings

* Add reauthentication to SmartThings

* Add reauthentication to SmartThings

* Add reauthentication to SmartThings
This commit is contained in:
Joost Lekkerkerker 2025-01-18 18:41:24 +01:00 committed by GitHub
parent b39c2719d7
commit fe8a93d62f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 173 additions and 55 deletions

View File

@ -10,12 +10,16 @@ import logging
from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError
from pysmartapp.event import EVENT_TYPE_DEVICE 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.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import HomeAssistant 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 import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send 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. # to import the modules.
await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS)
remove_entry = False
try: try:
# See if the app is already setup. This occurs when there are # See if the app is already setup. This occurs when there are
# installs in multiple SmartThings locations (valid use-case) # installs in multiple SmartThings locations (valid use-case)
@ -175,34 +178,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
broker.connect() broker.connect()
hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker
except APIInvalidGrant as ex:
raise ConfigEntryAuthFailed from ex
except ClientResponseError as ex: except ClientResponseError as ex:
if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
_LOGGER.exception( raise ConfigEntryError(
( "The access token is no longer valid. Please remove the integration and set up again."
"Unable to setup configuration entry '%s' - please reconfigure the" ) from ex
" integration" _LOGGER.debug(ex, exc_info=True)
), raise ConfigEntryNotReady from ex
entry.title,
)
remove_entry = True
else:
_LOGGER.debug(ex, exc_info=True)
raise ConfigEntryNotReady from ex
except (ClientConnectionError, RuntimeWarning) as ex: except (ClientConnectionError, RuntimeWarning) as ex:
_LOGGER.debug(ex, exc_info=True) _LOGGER.debug(ex, exc_info=True)
raise ConfigEntryNotReady from ex 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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True

View File

@ -1,5 +1,6 @@
"""Config flow to configure SmartThings.""" """Config flow to configure SmartThings."""
from collections.abc import Mapping
from http import HTTPStatus from http import HTTPStatus
import logging import logging
from typing import Any from typing import Any
@ -9,7 +10,7 @@ from pysmartthings import APIResponseError, AppOAuth, SmartThings
from pysmartthings.installedapp import format_install_url from pysmartthings.installedapp import format_install_url
import voluptuous as vol 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.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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) 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(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): def _show_step_pat(self, errors):
if self.access_token is None: 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( async def async_step_install(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@ -1,5 +1,7 @@
"""SmartApp functionality to receive cloud-push notifications.""" """SmartApp functionality to receive cloud-push notifications."""
from __future__ import annotations
import asyncio import asyncio
import functools import functools
import logging import logging
@ -27,6 +29,7 @@ from pysmartthings import (
) )
from homeassistant.components import cloud, webhook from homeassistant.components import cloud, webhook
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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) _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, hass: HomeAssistant,
app_id: str, app_id: str,
location_id: str, location_id: str,
@ -418,24 +421,34 @@ async def _continue_flow(
None, None,
) )
if flow is not None: if flow is not None:
await hass.config_entries.flow.async_configure( await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow)
flow["flow_id"],
{
CONF_INSTALLED_APP_ID: installed_app_id, async def _continue_flow(
CONF_REFRESH_TOKEN: refresh_token, hass: HomeAssistant,
}, app_id: str,
) installed_app_id: str,
_LOGGER.debug( refresh_token: str,
"Continued config flow '%s' for SmartApp '%s' under parent app '%s'", flow: ConfigFlowResult,
flow["flow_id"], ) -> None:
installed_app_id, await hass.config_entries.flow.async_configure(
app_id, 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): async def smartapp_install(hass: HomeAssistant, req, resp, app):
"""Handle a SmartApp installation and continue the config flow.""" """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 hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
) )
_LOGGER.debug( _LOGGER.debug(
@ -447,6 +460,27 @@ async def smartapp_install(hass: HomeAssistant, req, resp, app):
async def smartapp_update(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.""" """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 = next(
( (
entry entry
@ -466,7 +500,7 @@ async def smartapp_update(hass: HomeAssistant, req, resp, app):
app.app_id, 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 hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
) )
_LOGGER.debug( _LOGGER.debug(

View File

@ -7,7 +7,7 @@
}, },
"pat": { "pat": {
"title": "Enter Personal Access Token", "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": { "data": {
"access_token": "[%key:common::config_flow::data::access_token%]" "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.", "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%]" } "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": { "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.", "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": { "error": {
"token_invalid_format": "The token must be in the UID/GUID format", "token_invalid_format": "The token must be in the UID/GUID format",

View File

@ -14,6 +14,7 @@ from homeassistant.components.smartthings.const import (
CONF_APP_ID, CONF_APP_ID,
CONF_INSTALLED_APP_ID, CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID, CONF_LOCATION_ID,
CONF_REFRESH_TOKEN,
DOMAIN, DOMAIN,
) )
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET 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["type"] is FlowResultType.ABORT
assert result["reason"] == "no_available_locations" 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

View File

@ -23,6 +23,7 @@ from homeassistant.components.smartthings.const import (
PLATFORMS, PLATFORMS,
SIGNAL_SMARTTHINGS_UPDATE, SIGNAL_SMARTTHINGS_UPDATE,
) )
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.core_config import async_process_ha_core_config from homeassistant.core_config import async_process_ha_core_config
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
@ -68,17 +69,10 @@ async def test_unrecoverable_api_errors_create_new_flow(
) )
# Assert setup returns false # 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 not result
# Assert entry was removed and new flow created assert config_entry.state == ConfigEntryState.SETUP_ERROR
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"])
async def test_recoverable_api_errors_raise_not_ready( async def test_recoverable_api_errors_raise_not_ready(