From ffbc5b07f2e14b2da8efe5891cffa6fc44687de8 Mon Sep 17 00:00:00 2001 From: Oluwatobi Mustapha Date: Fri, 21 Aug 2026 10:49:31 +0100 Subject: [PATCH] Remove Supervisor refresh tokens (#179219) --- homeassistant/components/hassio/__init__.py | 16 +++---- tests/components/hassio/conftest.py | 46 ++++++++++++------- tests/components/hassio/test_init.py | 49 +++++++++++++-------- tests/conftest.py | 27 ++++++++---- 4 files changed, 87 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index bf1741ac238b..59c14de4460e 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -15,7 +15,7 @@ from aiohasupervisor.models import ( ) from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.auth.models import RefreshToken, User +from homeassistant.auth.models import User from homeassistant.components import frontend from homeassistant.components.homeassistant import async_set_stop_handler from homeassistant.components.onboarding import async_is_onboarded @@ -412,11 +412,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: translation_key="supervisor_update_pending", ) - # Get or create a refresh token for the Supervisor user - if user.refresh_tokens: - refresh_token = list(user.refresh_tokens.values())[0] - else: - refresh_token = await hass.auth.async_create_refresh_token(user) + # Supervisor authenticates through its dedicated Unix socket. + for refresh_token in list(user.refresh_tokens.values()): + hass.auth.async_remove_refresh_token(refresh_token) # Set up coordinators — these can raise ConfigEntryNotReady. # Register listeners only after all refreshes succeed to avoid accumulation @@ -492,7 +490,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config)) - async def update_hass_api(refresh_token: RefreshToken) -> None: + async def update_hass_api() -> None: """Update Home Assistant API data on Hass.io.""" # hass.config.api is always set here: hassio depends on http, and the # http integration assigns hass.config.api during its async_setup. @@ -500,7 +498,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options = HomeAssistantOptions( ssl=hass.config.api.use_ssl, port=hass.config.api.port, - refresh_token=refresh_token.token, + refresh_token=None, ) try: @@ -512,7 +510,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Push initial config to Supervisor and refresh issues state await asyncio.gather( - update_hass_api(refresh_token), + update_hass_api(), push_config(None), issues_coordinator.async_refresh(), ) diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index a1439058057d..7e20a621a8b4 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -10,7 +10,6 @@ from aiohasupervisor.models import AddonsStats, AddonState, InstalledAddonComple from aiohttp.test_utils import TestClient import pytest -from homeassistant.components.hassio.const import DATA_HASSIO_SUPERVISOR_USER from homeassistant.components.hassio.handler import HassIO from homeassistant.components.http.config import _DEFAULT_CONFIG as HTTP_DEFAULT_CONFIG from homeassistant.components.http.const import CONF_SERVER_PORT @@ -71,16 +70,23 @@ async def hassio_client_supervisor( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, hassio_stubs: None, -) -> TestClient: +) -> AsyncGenerator[TestClient]: """Return an authenticated HTTP client.""" - hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] - assert hassio_user.refresh_tokens - refresh_token = next(iter(hassio_user.refresh_tokens.values())) - access_token = hass.auth.async_create_access_token(refresh_token) - return await aiohttp_client( - hass.http.app, - headers={"Authorization": f"Bearer {access_token}"}, - ) + with ( + patch( + "homeassistant.components.hassio.auth.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.http.ban.is_supervisor_unix_socket_request", + return_value=True, + ), + ): + yield await aiohttp_client(hass.http.app) @pytest.fixture @@ -91,11 +97,21 @@ def hass_supervisor_ws_client( """Return a websocket client authenticated as the Supervisor user.""" async def create_client() -> WebSocketGenerator: - hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] - assert hassio_user.refresh_tokens - refresh_token = next(iter(hassio_user.refresh_tokens.values())) - access_token = hass.auth.async_create_access_token(refresh_token) - return await hass_ws_client(hass, access_token=access_token) + with ( + patch( + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.http.ban.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.websocket_api.http.is_supervisor_unix_socket_request", + return_value=True, + ), + ): + return await hass_ws_client(hass, supervisor_unix_socket=True) return create_client diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 21adea41feeb..220f104f64e6 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,7 +5,7 @@ from datetime import timedelta import os from pathlib import PurePath from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, call, patch +from unittest.mock import AsyncMock, Mock, call, patch from uuid import uuid4 from aiohasupervisor import SupervisorBadRequestError, SupervisorError @@ -312,7 +312,7 @@ async def test_setup_api_push_api_data( assert result assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY) + HomeAssistantOptions(ssl=False, port=9999, refresh_token=None) ) @@ -349,7 +349,7 @@ async def test_setup_api_push_api_data_server_host( assert result assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY) + HomeAssistantOptions(ssl=False, port=9999, refresh_token=None) ) @@ -364,21 +364,14 @@ async def test_setup_api_push_api_data_default( assert result assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=ANY) - ) - refresh_token = ( - supervisor_client.homeassistant.set_options.mock_calls[0].args[0].refresh_token + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] assert hassio_user.system_generated assert len(hassio_user.groups) == 1 assert hassio_user.groups[0].id == GROUP_ID_ADMIN assert hassio_user.name == "Supervisor" - for token in hassio_user.refresh_tokens.values(): - if token.token == refresh_token: - break - else: - pytest.fail("refresh token not found") + assert not hassio_user.refresh_tokens async def test_setup_adds_admin_group_to_user(hass: HomeAssistant) -> None: @@ -399,6 +392,7 @@ async def test_setup_adds_admin_group_to_user(hass: HomeAssistant) -> None: assert result assert user.is_admin + assert not user.refresh_tokens async def test_setup_migrate_user_name(hass: HomeAssistant) -> None: @@ -418,6 +412,7 @@ async def test_setup_migrate_user_name(hass: HomeAssistant) -> None: assert result assert user.name == "Supervisor" + assert not user.refresh_tokens async def test_setup_api_existing_hassio_user( @@ -425,7 +420,10 @@ async def test_setup_api_existing_hassio_user( ) -> None: """Test setup uses the user from config entry data.""" user = await hass.auth.async_create_system_user("Hass.io test") - token = await hass.auth.async_create_refresh_token(user) + refresh_tokens = [ + await hass.auth.async_create_refresh_token(user) for _ in range(2) + ] + access_token = hass.auth.async_create_access_token(refresh_tokens[0]) config_entry = MockConfigEntry( domain=DOMAIN, data={ENTRY_DATA_USER: user.id}, @@ -440,8 +438,10 @@ async def test_setup_api_existing_hassio_user( assert result assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=token.token) + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) + assert not user.refresh_tokens + assert hass.auth.async_validate_access_token(access_token) is None async def test_setup_migrates_legacy_hassio_store_to_config_entry( @@ -451,7 +451,7 @@ async def test_setup_migrates_legacy_hassio_store_to_config_entry( ) -> None: """Test setup migrates legacy hassio store user/options into config entry.""" user = await hass.auth.async_create_system_user("Hass.io test") - token = await hass.auth.async_create_refresh_token(user) + await hass.auth.async_create_refresh_token(user) config_entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) @@ -485,8 +485,9 @@ async def test_setup_migrates_legacy_hassio_store_to_config_entry( assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=token.token) + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) + assert not user.refresh_tokens async def test_setup_migrates_legacy_options_over_default_entry_options( @@ -496,7 +497,7 @@ async def test_setup_migrates_legacy_options_over_default_entry_options( ) -> None: """Test legacy update options override default config entry options.""" user = await hass.auth.async_create_system_user("Hass.io test") - token = await hass.auth.async_create_refresh_token(user) + await hass.auth.async_create_refresh_token(user) config_entry = MockConfigEntry( domain=DOMAIN, @@ -533,8 +534,9 @@ async def test_setup_migrates_legacy_options_over_default_entry_options( assert entry.options[OPTION_CORE_BACKUP_BEFORE_UPDATE] is True supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=token.token) + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) + assert not user.refresh_tokens async def test_setup_core_push_config( @@ -2040,6 +2042,15 @@ async def test_supervisor_issues_not_set_on_coordinator_failure( If a coordinator first-refresh raises ConfigEntryNotReady the issues listener must not be registered, preventing accumulation across retries. """ + user = await hass.auth.async_create_system_user("Hass.io test") + refresh_token = await hass.auth.async_create_refresh_token(user) + access_token = hass.auth.async_create_access_token(refresh_token) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ENTRY_DATA_USER: user.id}, + unique_id=DOMAIN, + ) + config_entry.add_to_hass(hass) supervisor_root_info.side_effect = SupervisorError() with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component(hass, DOMAIN, {}) @@ -2048,3 +2059,5 @@ async def test_supervisor_issues_not_set_on_coordinator_failure( entry = hass.config_entries.async_entries("hassio")[0] assert entry.state is ConfigEntryState.SETUP_RETRY assert DATA_KEY_SUPERVISOR_ISSUES not in hass.data + assert not user.refresh_tokens + assert hass.auth.async_validate_access_token(access_token) is None diff --git a/tests/conftest.py b/tests/conftest.py index a65030266e4b..665d019330a2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -959,22 +959,31 @@ def hass_ws_client( """Websocket client fixture connected to websocket server.""" async def create_client( - hass: HomeAssistant = hass, access_token: str | None = hass_access_token + hass: HomeAssistant = hass, + access_token: str | None = hass_access_token, + supervisor_unix_socket: bool = False, ) -> MockHAClientWebSocket: - """Create a websocket client.""" + """Create a client, skipping token auth for Supervisor Unix sockets.""" assert await async_setup_component(hass, "websocket_api", {}) client = await aiohttp_client(hass.http.app) websocket = await client.ws_connect(URL) auth_resp = await websocket.receive_json() - assert auth_resp["type"] == TYPE_AUTH_REQUIRED - - if access_token is None: - await websocket.send_json({"type": TYPE_AUTH, "access_token": "incorrect"}) + if supervisor_unix_socket: + assert auth_resp["type"] == TYPE_AUTH_OK else: - await websocket.send_json({"type": TYPE_AUTH, "access_token": access_token}) + assert auth_resp["type"] == TYPE_AUTH_REQUIRED - auth_ok = await websocket.receive_json() - assert auth_ok["type"] == TYPE_AUTH_OK + if access_token is None: + await websocket.send_json( + {"type": TYPE_AUTH, "access_token": "incorrect"} + ) + else: + await websocket.send_json( + {"type": TYPE_AUTH, "access_token": access_token} + ) + + auth_ok = await websocket.receive_json() + assert auth_ok["type"] == TYPE_AUTH_OK def _get_next_id() -> Generator[int]: i = 0