Remove Supervisor refresh tokens (#179219)

This commit is contained in:
Oluwatobi Mustapha
2026-08-21 11:49:31 +02:00
committed by GitHub
parent 59f40da229
commit ffbc5b07f2
4 changed files with 87 additions and 51 deletions
+7 -9
View File
@@ -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(),
)
+31 -15
View File
@@ -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
+31 -18
View File
@@ -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
+18 -9
View File
@@ -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