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>
This commit is contained in:
Thomas55555 2024-03-08 11:30:39 +01:00 committed by GitHub
parent 4893087a7e
commit 9ba5159ae2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 181 additions and 6 deletions

View File

@ -3,12 +3,12 @@
import logging import logging
from aioautomower.session import AutomowerSession from aioautomower.session import AutomowerSession
from aiohttp import ClientError from aiohttp import ClientResponseError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant 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 homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from . import api from . import api
@ -35,7 +35,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
automower_api = AutomowerSession(api_api) automower_api = AutomowerSession(api_api)
try: try:
await api_api.async_get_access_token() 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 raise ConfigEntryNotReady from err
coordinator = AutomowerDataUpdateCoordinator(hass, automower_api, entry) coordinator = AutomowerDataUpdateCoordinator(hass, automower_api, entry)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()

View File

@ -1,10 +1,11 @@
"""Config flow to add the integration via the UI.""" """Config flow to add the integration via the UI."""
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from aioautomower.utils import async_structure_token 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.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
@ -22,11 +23,16 @@ class HusqvarnaConfigFlowHandler(
VERSION = 1 VERSION = 1
DOMAIN = DOMAIN DOMAIN = DOMAIN
reauth_entry: ConfigEntry | None = None
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow.""" """Create an entry for the flow."""
token = data[CONF_TOKEN] token = data[CONF_TOKEN]
user_id = token[CONF_USER_ID] 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]) structured_token = await async_structure_token(token[CONF_ACCESS_TOKEN])
first_name = structured_token.user.first_name first_name = structured_token.user.first_name
last_name = structured_token.user.last_name last_name = structured_token.user.last_name
@ -41,3 +47,20 @@ class HusqvarnaConfigFlowHandler(
def logger(self) -> logging.Logger: def logger(self) -> logging.Logger:
"""Return logger.""" """Return logger."""
return logging.getLogger(__name__) 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()

View File

@ -1,6 +1,10 @@
{ {
"config": { "config": {
"step": { "step": {
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Husqvarna Automower integration needs to re-authenticate your account"
},
"pick_implementation": { "pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_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%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "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": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -127,3 +127,148 @@ async def test_config_non_unique_profile(
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured" 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"
)

View File

@ -41,7 +41,7 @@ async def test_load_unload_entry(
( (
time.time() - 3600, time.time() - 3600,
http.HTTPStatus.UNAUTHORIZED, http.HTTPStatus.UNAUTHORIZED,
ConfigEntryState.SETUP_RETRY, # Will trigger reauth in the future ConfigEntryState.SETUP_ERROR,
), ),
( (
time.time() - 3600, time.time() - 3600,