From a3b60cb054f272227a18ca0c1e704c8523496730 Mon Sep 17 00:00:00 2001 From: Jake Martin Date: Mon, 3 Jun 2024 12:18:15 +0100 Subject: [PATCH] Add Monzo config reauth (#117726) * Add reauth config flow * Trigger reauth on Monzo AuthorisaionExpiredError * Add missing abort strings * Use FlowResultType enum * One extra == swapped for is * Use helper in reauth * Patch correct function in reauth test * Remove unecessary ** * Swap patch and calls check for access token checks * Do reauth trigger test without patch * Remove unnecessary str() on user_id - always str anyway * Update tests/components/monzo/test_config_flow.py --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/monzo/config_flow.py | 37 +++- homeassistant/components/monzo/coordinator.py | 11 +- homeassistant/components/monzo/strings.json | 8 +- tests/components/monzo/test_config_flow.py | 160 +++++++++++++++++- 4 files changed, 204 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/monzo/config_flow.py b/homeassistant/components/monzo/config_flow.py index 1d5bc3147b1..2eb51b4d305 100644 --- a/homeassistant/components/monzo/config_flow.py +++ b/homeassistant/components/monzo/config_flow.py @@ -2,12 +2,13 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult from homeassistant.const import CONF_TOKEN from homeassistant.helpers import config_entry_oauth2_flow @@ -22,6 +23,7 @@ class MonzoFlowHandler( DOMAIN = DOMAIN oauth_data: dict[str, Any] + reauth_entry: ConfigEntry | None = None @property def logger(self) -> logging.Logger: @@ -33,7 +35,11 @@ class MonzoFlowHandler( ) -> ConfigFlowResult: """Wait for the user to confirm in-app approval.""" if user_input is not None: - return self.async_create_entry(title=DOMAIN, data={**self.oauth_data}) + if not self.reauth_entry: + return self.async_create_entry(title=DOMAIN, data=self.oauth_data) + return self.async_update_reload_and_abort( + self.reauth_entry, data={**self.reauth_entry.data, **self.oauth_data} + ) data_schema = vol.Schema({vol.Required("confirm"): bool}) @@ -43,10 +49,29 @@ class MonzoFlowHandler( async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the flow.""" - user_id = str(data[CONF_TOKEN]["user_id"]) - await self.async_set_unique_id(user_id) - self._abort_if_unique_id_configured() - self.oauth_data = data + user_id = data[CONF_TOKEN]["user_id"] + if not self.reauth_entry: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + elif self.reauth_entry.unique_id != user_id: + return self.async_abort(reason="wrong_account") return await self.async_step_await_approval_confirmation() + + 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/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py index 67fff38c4f8..223d7b05ffe 100644 --- a/homeassistant/components/monzo/coordinator.py +++ b/homeassistant/components/monzo/coordinator.py @@ -5,7 +5,10 @@ from datetime import timedelta import logging from typing import Any +from monzopy import AuthorisationExpiredError + from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .api import AuthenticatedMonzoAPI @@ -37,6 +40,10 @@ class MonzoCoordinator(DataUpdateCoordinator[MonzoData]): async def _async_update_data(self) -> MonzoData: """Fetch data from Monzo API.""" - accounts = await self.api.user_account.accounts() - pots = await self.api.user_account.pots() + try: + accounts = await self.api.user_account.accounts() + pots = await self.api.user_account.pots() + except AuthorisationExpiredError as err: + raise ConfigEntryAuthFailed from err + return MonzoData(accounts, pots) diff --git a/homeassistant/components/monzo/strings.json b/homeassistant/components/monzo/strings.json index 5c0a894a2e2..e4ec34a8459 100644 --- a/homeassistant/components/monzo/strings.json +++ b/homeassistant/components/monzo/strings.json @@ -4,6 +4,10 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Monzo integration needs to re-authenticate your account" + }, "await_approval_confirmation": { "title": "Confirm in Monzo app", "description": "Before proceeding, open your Monzo app and approve the request from Home Assistant.", @@ -19,7 +23,9 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "Wrong account: The credentials provided do not match this Monzo account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/monzo/test_config_flow.py b/tests/components/monzo/test_config_flow.py index bd4d8644457..7ad4c072723 100644 --- a/tests/components/monzo/test_config_flow.py +++ b/tests/components/monzo/test_config_flow.py @@ -1,13 +1,17 @@ """Tests for config flow.""" +from datetime import timedelta from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory +from monzopy import AuthorisationExpiredError + from homeassistant.components.monzo.application_credentials import ( OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) from homeassistant.components.monzo.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -15,7 +19,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import setup_integration from .conftest import CLIENT_ID, USER_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -59,7 +63,7 @@ async def test_full_flow( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, - "user_id": 600, + "user_id": "600", }, ) with patch( @@ -136,3 +140,153 @@ async def test_config_non_unique_profile( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_config_reauth_profile( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + polling_config_entry: MockConfigEntry, + monzo: AsyncMock, + current_request_with_host: None, +) -> None: + """Test reauth an existing profile reauthenticates the config entry.""" + await setup_integration(hass, polling_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": polling_config_entry.entry_id, + }, + data=polling_config_entry.data, + ) + assert result["type"] is FlowResultType.FORM + 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}/?" + f"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.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "new-mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": str(USER_ID), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "await_approval_confirmation" + assert polling_config_entry.data["token"]["access_token"] == "mock-access-token" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"confirm": True} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert result + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert polling_config_entry.data["token"]["access_token"] == "new-mock-access-token" + + +async def test_config_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + polling_config_entry: MockConfigEntry, + current_request_with_host: None, +) -> None: + """Test reauth with wrong account.""" + await setup_integration(hass, polling_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": polling_config_entry.entry_id, + }, + data=polling_config_entry.data, + ) + assert result["type"] is FlowResultType.FORM + 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}/?" + f"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.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": 12346, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +async def test_api_can_trigger_reauth( + hass: HomeAssistant, + polling_config_entry: MockConfigEntry, + monzo: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test reauth an existing profile reauthenticates the config entry.""" + await setup_integration(hass, polling_config_entry) + + monzo.user_account.accounts.side_effect = AuthorisationExpiredError() + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == SOURCE_REAUTH