From 9ba5159ae27e40def6e2a893b8281a46410913ae Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 8 Mar 2024 11:30:39 +0100 Subject: [PATCH] Add reauthentication for husqvarna_automower (#109930) * Add reauthentication for husqvarna_automower * Remove unneded lines * Don't extract token on reauth * Update homeassistant/components/husqvarna_automower/config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/husqvarna_automower/conftest.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Use helper * Test if authentication is done with the right account * switch to ConfigFlowResult --------- Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> --- .../husqvarna_automower/__init__.py | 8 +- .../husqvarna_automower/config_flow.py | 25 ++- .../husqvarna_automower/strings.json | 7 +- .../husqvarna_automower/test_config_flow.py | 145 ++++++++++++++++++ .../husqvarna_automower/test_init.py | 2 +- 5 files changed, 181 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 20218229385..473c67c4ea8 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -3,12 +3,12 @@ import logging from aioautomower.session import AutomowerSession -from aiohttp import ClientError +from aiohttp import ClientResponseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import api @@ -35,7 +35,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: automower_api = AutomowerSession(api_api) try: await api_api.async_get_access_token() - except ClientError as err: + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed from err raise ConfigEntryNotReady from err coordinator = AutomowerDataUpdateCoordinator(hass, automower_api, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py index 11d3abb29a0..5ba0aeae154 100644 --- a/homeassistant/components/husqvarna_automower/config_flow.py +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -1,10 +1,11 @@ """Config flow to add the integration via the UI.""" +from collections.abc import Mapping import logging from typing import Any from aioautomower.utils import async_structure_token -from homeassistant.config_entries import ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers import config_entry_oauth2_flow @@ -22,11 +23,16 @@ class HusqvarnaConfigFlowHandler( VERSION = 1 DOMAIN = DOMAIN + reauth_entry: ConfigEntry | None = None async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the flow.""" token = data[CONF_TOKEN] user_id = token[CONF_USER_ID] + if self.reauth_entry: + if self.reauth_entry.unique_id != user_id: + return self.async_abort(reason="wrong_account") + return self.async_update_reload_and_abort(self.reauth_entry, data=data) structured_token = await async_structure_token(token[CONF_ACCESS_TOKEN]) first_name = structured_token.user.first_name last_name = structured_token.user.last_name @@ -41,3 +47,20 @@ class HusqvarnaConfigFlowHandler( def logger(self) -> logging.Logger: """Return logger.""" return logging.getLogger(__name__) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index d6017de2bd7..2d42172506d 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -1,6 +1,10 @@ { "config": { "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Husqvarna Automower integration needs to re-authenticate your account" + }, "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" } @@ -17,7 +21,8 @@ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "You can only reauthenticate this entry with the same Husqvarna account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/husqvarna_automower/test_config_flow.py b/tests/components/husqvarna_automower/test_config_flow.py index fcf9fbffa0c..e22ab7718ec 100644 --- a/tests/components/husqvarna_automower/test_config_flow.py +++ b/tests/components/husqvarna_automower/test_config_flow.py @@ -127,3 +127,148 @@ async def test_config_non_unique_profile( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + current_request_with_host: None, + mock_automower_client: AsyncMock, + jwt, +) -> None: + """Test the reauthentication case updates the existing config entry.""" + + mock_config_entry.add_to_hass(hass) + + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = 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", + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + 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={ + "access_token": "mock-updated-token", + "scope": "iam:read amc:api", + "expires_in": 86399, + "refresh_token": "mock-refresh-token", + "provider": "husqvarna", + "user_id": USER_ID, + "token_type": "Bearer", + "expires_at": 1697753347, + }, + ) + + with patch( + "homeassistant.components.husqvarna_automower.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert result.get("type") == "abort" + assert result.get("reason") == "reauth_successful" + + assert mock_config_entry.unique_id == USER_ID + assert "token" in mock_config_entry.data + # Verify access token is refreshed + assert mock_config_entry.data["token"].get("access_token") == "mock-updated-token" + assert mock_config_entry.data["token"].get("refresh_token") == "mock-refresh-token" + + +async def test_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + current_request_with_host: None, + mock_automower_client: AsyncMock, + jwt, +) -> None: + """Test the reauthentication aborts, if user tries to reauthenticate with another account.""" + + mock_config_entry.add_to_hass(hass) + + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = 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", + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + 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={ + "access_token": "mock-updated-token", + "scope": "iam:read amc:api", + "expires_in": 86399, + "refresh_token": "mock-refresh-token", + "provider": "husqvarna", + "user_id": "wrong-user-id", + "token_type": "Bearer", + "expires_at": 1697753347, + }, + ) + + with patch( + "homeassistant.components.husqvarna_automower.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert result.get("type") == "abort" + assert result.get("reason") == "wrong_account" + + assert mock_config_entry.unique_id == USER_ID + assert "token" in mock_config_entry.data + # Verify access token is like before + assert mock_config_entry.data["token"].get("access_token") == jwt + assert ( + mock_config_entry.data["token"].get("refresh_token") + == "3012bc9f-7a65-4240-b817-9154ffdcc30f" + ) diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index c11e4ac4cc7..3fba90d7032 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -41,7 +41,7 @@ async def test_load_unload_entry( ( time.time() - 3600, http.HTTPStatus.UNAUTHORIZED, - ConfigEntryState.SETUP_RETRY, # Will trigger reauth in the future + ConfigEntryState.SETUP_ERROR, ), ( time.time() - 3600, -- GitLab