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 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

View File

@ -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:

View File

@ -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(

View File

@ -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",

View File

@ -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

View File

@ -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(