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 from aiohomeconnect.client import Client as HomeConnectClient
import aiohttp import aiohttp
import jwt
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -110,14 +111,19 @@ async def async_migrate_entry(
"""Migrate old entry.""" """Migrate old entry."""
_LOGGER.debug("Migrating from version %s", entry.version) _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 @callback
def update_unique_id( def update_unique_id(
entity_entry: RegistryEntry, entity_entry: RegistryEntry,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Update unique ID of entity entry.""" """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}"): if entity_entry.unique_id.endswith(f"-{old_id_suffix}"):
return { return {
"new_unique_id": entity_entry.unique_id.replace( "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) await async_migrate_entries(hass, entry.entry_id, update_unique_id)
hass.config_entries.async_update_entry(entry, minor_version=2) 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) _LOGGER.debug("Migration to version %s successful", entry.version)
return True return True

View File

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

View File

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

View File

@ -14,13 +14,15 @@
} }
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"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%]",
"wrong_account": "Please ensure you reconfigure against the same account."
}, },
"create_entry": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "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_ID = "1234"
CLIENT_SECRET = "5678" CLIENT_SECRET = "5678"
FAKE_ACCESS_TOKEN = "some-access-token" FAKE_ACCESS_TOKEN = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
)
FAKE_REFRESH_TOKEN = "some-refresh-token" FAKE_REFRESH_TOKEN = "some-refresh-token"
FAKE_AUTH_IMPL = "conftest-imported-cred" 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, "auth_implementation": FAKE_AUTH_IMPL,
"token": token_entry, "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 @pytest.fixture
async def setup_credentials(hass: HomeAssistant) -> None: async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup credentials.""" """Fixture to setup credentials."""

View File

@ -13,10 +13,13 @@ from homeassistant.components.application_credentials import (
async_import_client_credential, async_import_client_credential,
) )
from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.home_connect.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
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
from .conftest import FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator from tests.typing import ClientSessionGenerator
@ -64,8 +67,8 @@ async def test_full_flow(
aioclient_mock.post( aioclient_mock.post(
OAUTH2_TOKEN, OAUTH2_TOKEN,
json={ json={
"refresh_token": "mock-refresh-token", "refresh_token": FAKE_REFRESH_TOKEN,
"access_token": "mock-access-token", "access_token": FAKE_ACCESS_TOKEN,
"type": "Bearer", "type": "Bearer",
"expires_in": 60, "expires_in": 60,
}, },
@ -77,23 +80,64 @@ async def test_full_flow(
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done() 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 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: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test we only allow one config entry.""" """Test we only allow one config entry per account."""
config_entry.add_to_hass(hass) 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( result = await hass.config_entries.flow.async_init(
"home_connect", context={"source": config_entries.SOURCE_USER} "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["type"] == "abort"
assert result["reason"] == "single_instance_allowed" assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("current_request_with_host") @pytest.mark.usefixtures("current_request_with_host")
@ -129,8 +173,8 @@ async def test_reauth_flow(
aioclient_mock.post( aioclient_mock.post(
OAUTH2_TOKEN, OAUTH2_TOKEN,
json={ json={
"refresh_token": "mock-refresh-token", "refresh_token": FAKE_REFRESH_TOKEN,
"access_token": "mock-access-token", "access_token": FAKE_ACCESS_TOKEN,
"type": "Bearer", "type": "Bearer",
"expires_in": 60, "expires_in": 60,
}, },
@ -142,9 +186,61 @@ async def test_reauth_flow(
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done() 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 assert len(mock_setup_entry.mock_calls) == 1
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful" 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" program = "Dishcare.Dishwasher.Program.Eco50"
translation_key = bsh_key_to_translation_key(program) translation_key = bsh_key_to_translation_key(program)
assert RE_TRANSLATION_KEY.match(translation_key) 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