Allow multiple config entries in Home Connect (#143935)

* Allow multiple config entries in Home Connect

* Config entry migration

* Create new entry if reauth flow is completed with other account

* Abort if different account on reauth
This commit is contained in:
J. Diego Rodríguez Royo 2025-04-30 16:22:18 +02:00 committed by GitHub
parent 819be719ef
commit 4061314cd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 187 additions and 33 deletions

View File

@ -7,6 +7,7 @@ from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient
import aiohttp
import jwt
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
@ -110,14 +111,19 @@ async def async_migrate_entry(
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", entry.version)
if entry.version == 1 and entry.minor_version == 1:
if entry.version == 1:
match entry.minor_version:
case 1:
@callback
def update_unique_id(
entity_entry: RegistryEntry,
) -> dict[str, Any] | None:
"""Update unique ID of entity entry."""
for old_id_suffix, new_id_suffix in OLD_NEW_UNIQUE_ID_SUFFIX_MAP.items():
for (
old_id_suffix,
new_id_suffix,
) in OLD_NEW_UNIQUE_ID_SUFFIX_MAP.items():
if entity_entry.unique_id.endswith(f"-{old_id_suffix}"):
return {
"new_unique_id": entity_entry.unique_id.replace(
@ -129,6 +135,15 @@ async def async_migrate_entry(
await async_migrate_entries(hass, entry.entry_id, update_unique_id)
hass.config_entries.async_update_entry(entry, minor_version=2)
case 2:
hass.config_entries.async_update_entry(
entry,
minor_version=3,
unique_id=jwt.decode(
entry.data["token"]["access_token"],
options={"verify_signature": False},
)["sub"],
)
_LOGGER.debug("Migration to version %s successful", entry.version)
return True

View File

@ -4,6 +4,7 @@ from collections.abc import Mapping
import logging
from typing import Any
import jwt
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
@ -19,7 +20,7 @@ class OAuth2FlowHandler(
DOMAIN = DOMAIN
MINOR_VERSION = 2
MINOR_VERSION = 3
@property
def logger(self) -> logging.Logger:
@ -45,9 +46,15 @@ class OAuth2FlowHandler(
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates=data,
await self.async_set_unique_id(
jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
)["sub"]
)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=data
)
self._abort_if_unique_id_configured()
return await super().async_oauth_create_entry(data)

View File

@ -8,6 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"requirements": ["aiohomeconnect==0.17.0"],
"single_config_entry": true,
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@ -14,13 +14,15 @@
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"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%]",
"wrong_account": "Please ensure you reconfigure against the same account."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -46,7 +46,11 @@ from tests.common import MockConfigEntry, load_fixture
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
FAKE_ACCESS_TOKEN = "some-access-token"
FAKE_ACCESS_TOKEN = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
)
FAKE_REFRESH_TOKEN = "some-refresh-token"
FAKE_AUTH_IMPL = "conftest-imported-cred"
@ -84,7 +88,8 @@ def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry:
"auth_implementation": FAKE_AUTH_IMPL,
"token": token_entry,
},
minor_version=2,
minor_version=3,
unique_id="1234567890",
)
@ -101,6 +106,19 @@ def mock_config_entry_v1_1(token_entry: dict[str, Any]) -> MockConfigEntry:
)
@pytest.fixture(name="config_entry_v1_2")
def mock_config_entry_v1_2(token_entry: dict[str, Any]) -> MockConfigEntry:
"""Fixture for a config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": FAKE_AUTH_IMPL,
"token": token_entry,
},
minor_version=2,
)
@pytest.fixture
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup credentials."""

View File

@ -13,10 +13,13 @@ from homeassistant.components.application_credentials import (
async_import_client_credential,
)
from homeassistant.components.home_connect.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from .conftest import FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@ -64,8 +67,8 @@ async def test_full_flow(
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"refresh_token": FAKE_REFRESH_TOKEN,
"access_token": FAKE_ACCESS_TOKEN,
"type": "Bearer",
"expires_in": 60,
},
@ -77,23 +80,64 @@ async def test_full_flow(
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890")
assert len(mock_setup_entry.mock_calls) == 1
async def test_prevent_multiple_config_entries(
@pytest.mark.usefixtures("current_request_with_host")
async def test_prevent_reconfiguring_same_account(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
) -> None:
"""Test we only allow one config entry."""
"""Test we only allow one config entry per account."""
config_entry.add_to_hass(hass)
assert await setup.async_setup_component(hass, "home_connect", {})
await async_import_client_credential(
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET)
)
result = await hass.config_entries.flow.async_init(
"home_connect", context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["type"] is FlowResultType.EXTERNAL_STEP
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 == HTTPStatus.OK
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": FAKE_REFRESH_TOKEN,
"access_token": FAKE_ACCESS_TOKEN,
"type": "Bearer",
"expires_in": 60,
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done()
assert result["type"] == "abort"
assert result["reason"] == "single_instance_allowed"
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("current_request_with_host")
@ -129,8 +173,8 @@ async def test_reauth_flow(
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"refresh_token": FAKE_REFRESH_TOKEN,
"access_token": FAKE_ACCESS_TOKEN,
"type": "Bearer",
"expires_in": 60,
},
@ -142,9 +186,61 @@ async def test_reauth_flow(
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890")
assert entry
assert entry.state is ConfigEntryState.LOADED
assert len(mock_setup_entry.mock_calls) == 1
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
@pytest.mark.usefixtures("current_request_with_host")
async def test_reauth_flow_with_different_account(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test reauth flow."""
result = await config_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"], {})
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
_client = await hass_client_no_auth()
resp = await _client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == HTTPStatus.OK
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": FAKE_REFRESH_TOKEN,
"access_token": (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
".eyJzdWIiOiJBQkNERSIsIm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp0cnVlLCJpYXQiOjE1MTYyMzkwMjJ9"
".Q9z9JT4qgNg9Y9ki61jzvd69j043GFWJk9HNYosAPzs"
),
"type": "Bearer",
"expires_in": 60,
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "wrong_account"

View File

@ -358,3 +358,20 @@ async def test_bsh_key_transformations() -> None:
program = "Dishcare.Dishwasher.Program.Eco50"
translation_key = bsh_key_to_translation_key(program)
assert RE_TRANSLATION_KEY.match(translation_key)
async def test_config_entry_unique_id_migration(
hass: HomeAssistant,
config_entry_v1_2: MockConfigEntry,
) -> None:
"""Test that old config entries use the unique id obtained from the JWT subject."""
config_entry_v1_2.add_to_hass(hass)
assert config_entry_v1_2.unique_id != "1234567890"
assert config_entry_v1_2.minor_version == 2
await hass.config_entries.async_setup(config_entry_v1_2.entry_id)
await hass.async_block_till_done()
assert config_entry_v1_2.unique_id == "1234567890"
assert config_entry_v1_2.minor_version == 3