mirror of
https://github.com/home-assistant/core.git
synced 2025-07-10 06:47:09 +00:00
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:
parent
b39c2719d7
commit
fe8a93d62f
@ -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:
|
||||
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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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,6 +421,16 @@ async def _continue_flow(
|
||||
None,
|
||||
)
|
||||
if flow is not None:
|
||||
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"],
|
||||
{
|
||||
@ -435,7 +448,7 @@ async def _continue_flow(
|
||||
|
||||
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(
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user