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 <joostlek@outlook.com>
This commit is contained in:
Jake Martin 2024-06-03 12:18:15 +01:00 committed by GitHub
parent ef7c7f1c05
commit a3b60cb054
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 204 additions and 12 deletions

View File

@ -2,12 +2,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
import voluptuous as vol 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.const import CONF_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
@ -22,6 +23,7 @@ class MonzoFlowHandler(
DOMAIN = DOMAIN DOMAIN = DOMAIN
oauth_data: dict[str, Any] oauth_data: dict[str, Any]
reauth_entry: ConfigEntry | None = None
@property @property
def logger(self) -> logging.Logger: def logger(self) -> logging.Logger:
@ -33,7 +35,11 @@ class MonzoFlowHandler(
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Wait for the user to confirm in-app approval.""" """Wait for the user to confirm in-app approval."""
if user_input is not None: 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}) 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: async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow.""" """Create an entry for the flow."""
user_id = str(data[CONF_TOKEN]["user_id"]) self.oauth_data = data
user_id = data[CONF_TOKEN]["user_id"]
if not self.reauth_entry:
await self.async_set_unique_id(user_id) await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
elif self.reauth_entry.unique_id != user_id:
self.oauth_data = data return self.async_abort(reason="wrong_account")
return await self.async_step_await_approval_confirmation() 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()

View File

@ -5,7 +5,10 @@ from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from monzopy import AuthorisationExpiredError
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .api import AuthenticatedMonzoAPI from .api import AuthenticatedMonzoAPI
@ -37,6 +40,10 @@ class MonzoCoordinator(DataUpdateCoordinator[MonzoData]):
async def _async_update_data(self) -> MonzoData: async def _async_update_data(self) -> MonzoData:
"""Fetch data from Monzo API.""" """Fetch data from Monzo API."""
try:
accounts = await self.api.user_account.accounts() accounts = await self.api.user_account.accounts()
pots = await self.api.user_account.pots() pots = await self.api.user_account.pots()
except AuthorisationExpiredError as err:
raise ConfigEntryAuthFailed from err
return MonzoData(accounts, pots) return MonzoData(accounts, pots)

View File

@ -4,6 +4,10 @@
"pick_implementation": { "pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_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": { "await_approval_confirmation": {
"title": "Confirm in Monzo app", "title": "Confirm in Monzo app",
"description": "Before proceeding, open your Monzo app and approve the request from Home Assistant.", "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%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "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": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -1,13 +1,17 @@
"""Tests for config flow.""" """Tests for config flow."""
from datetime import timedelta
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from monzopy import AuthorisationExpiredError
from homeassistant.components.monzo.application_credentials import ( from homeassistant.components.monzo.application_credentials import (
OAUTH2_AUTHORIZE, OAUTH2_AUTHORIZE,
OAUTH2_TOKEN, OAUTH2_TOKEN,
) )
from homeassistant.components.monzo.const import DOMAIN 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.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow 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 . import setup_integration
from .conftest import CLIENT_ID, USER_ID 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.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator from tests.typing import ClientSessionGenerator
@ -59,7 +63,7 @@ async def test_full_flow(
"access_token": "mock-access-token", "access_token": "mock-access-token",
"type": "Bearer", "type": "Bearer",
"expires_in": 60, "expires_in": 60,
"user_id": 600, "user_id": "600",
}, },
) )
with patch( with patch(
@ -136,3 +140,153 @@ 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"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured" 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