Raise HomeAssistantAuthError when Core rejects Supervisor credentials

Core answers 401 on the hassio_auth views when the caller is not the
Supervisor user it knows about (its _check_access), which happens when
the hassio config entry references a different Supervisor user than the
one the socket auth middleware resolves. Supervisor so far treated that
401 like any other non-200: change_password reported "Username '...'
does not exist. Check list of users using 'ha auth list'", and
_backend_login logged "Unauthorized login" for the app's user and evicted
the cached credentials. Both blame the wrong party: the user's
credentials were never checked, and the cache eviction also breaks the
offline login fallback for that user.

Handle the 401 where it originates. make_request now raises the existing
HomeAssistantAuthError when Core answers 401 over the Unix socket, or
still answers 401 over TCP after the one token refresh it already
performs. Previously the TCP case fell out of the retry loop without
yielding, which asynccontextmanager turns into "generator didn't yield".

HomeAssistantAuthError additionally becomes an APIError with status 500
and a stable error key, so it propagates through api_process with a
message that points at Supervisor's own authentication instead of the
requested username. The auth module re-raises it ahead of its generic
HomeAssistantAPIError handling and leaves the cache untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Stefan Agner
2026-09-07 10:55:50 +02:00
co-authored by Claude Fable 5.1
parent 2cd1fb514f
commit f91ca71d40
5 changed files with 183 additions and 5 deletions
+7
View File
@@ -14,6 +14,7 @@ from .exceptions import (
AuthListUsersError,
AuthPasswordResetError,
HomeAssistantAPIError,
HomeAssistantAuthError,
HomeAssistantWSError,
)
from .utils.common import FileConfiguration
@@ -131,6 +132,10 @@ class Auth(FileConfiguration, CoreSysAttributes):
_LOGGER.warning("Unauthorized login for '%s'", username)
await self._dismatch_cache(username, password)
return False
except HomeAssistantAuthError:
# Core rejected Supervisor, not the app's user. The credentials
# were never checked, so leave the cache alone.
raise
except HomeAssistantAPIError as err:
_LOGGER.error("Can't request auth on Home Assistant: %s", err)
finally:
@@ -151,6 +156,8 @@ class Auth(FileConfiguration, CoreSysAttributes):
return
_LOGGER.warning("The user '%s' is not registered", username)
except HomeAssistantAuthError:
raise
except HomeAssistantAPIError as err:
_LOGGER.error("Can't request password reset on Home Assistant: %s", err)
+9 -2
View File
@@ -218,8 +218,15 @@ class HomeAssistantAPIError(HomeAssistantError):
"""Home Assistant API exception."""
class HomeAssistantAuthError(HomeAssistantAPIError):
"""Home Assistant Auth API exception."""
class HomeAssistantAuthError(HomeAssistantAPIError, APIError):
"""Supervisor could not authenticate itself against Home Assistant."""
status = 500
error_key = "home_assistant_auth_error"
message_template = (
"Supervisor could not authenticate with Home Assistant. "
"Check Supervisor logs for details"
)
class HomeAssistantWSError(HomeAssistantAPIError):
+17
View File
@@ -289,6 +289,8 @@ class HomeAssistantAPI(CoreSysAttributes):
Raises:
HomeAssistantAPIError: When request cannot be completed due to
network errors, timeouts, or connection failures
HomeAssistantAuthError: When Core rejects Supervisor's own
credentials (HTTP 401), after one token refresh over TCP
"""
await self._ensure_core_running()
@@ -318,6 +320,14 @@ class HomeAssistantAPI(CoreSysAttributes):
if resp.status == 401 and not self.use_unix_socket:
self._access_token = None
continue
if resp.status == 401:
# Over the Unix socket there is no token to refresh:
# Core does not accept the Supervisor user itself.
_LOGGER.error(
"Home Assistant rejected Supervisor credentials on %s",
path,
)
raise HomeAssistantAuthError
yield resp
return
except TimeoutError as err:
@@ -327,6 +337,13 @@ class HomeAssistantAPI(CoreSysAttributes):
_LOGGER.debug("Error on call %s: %s", url, err)
raise HomeAssistantAPIError(str(err)) from err
# Core still answered 401 with a freshly refreshed token.
_LOGGER.error(
"Home Assistant rejected Supervisor credentials on %s after token refresh",
path,
)
raise HomeAssistantAuthError
async def _get_json(self, path: str) -> dict[str, Any]:
"""Return Home Assistant get API."""
async with self.make_request("get", path) as resp:
+76 -1
View File
@@ -1,7 +1,8 @@
"""Test auth API."""
import asyncio
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from aiohttp.hdrs import WWW_AUTHENTICATE
from aiohttp.test_utils import TestClient
@@ -150,6 +151,37 @@ async def test_failed_password_reset(
assert expected_log in caplog.text
async def test_password_reset_supervisor_rejected_by_core(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
websession: MagicMock,
):
"""Test password reset when Core rejects the Supervisor's own credentials."""
api_client, prefix = api_client_with_prefix
websession.request = MagicMock(return_value=MockResponse(status=401))
with (
patch.object(type(coresys.homeassistant.api), "use_unix_socket", True),
patch.object(
type(coresys.homeassistant.api),
"session",
new_callable=PropertyMock,
return_value=websession,
),
):
resp = await api_client.post(
f"{prefix}/auth/reset", json={"username": "john", "password": "doe"}
)
assert resp.status == 500
body = await resp.json()
assert body["error_key"] == "home_assistant_auth_error"
assert body["message"] == (
"Supervisor could not authenticate with Home Assistant. "
"Check Supervisor logs for details"
)
async def test_list_users(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
@@ -252,6 +284,49 @@ async def test_auth_json_invalid_credentials(
assert resp.status == 401
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_auth_supervisor_rejected_by_core(
api_client: TestClient,
coresys: CoreSys,
websession: MagicMock,
install_app_ssh: App,
):
"""Test app login when Core rejects the Supervisor's own credentials.
The app user's credentials were never checked, so a cached login must
survive and a fresh login must report the Supervisor-side failure.
"""
websession.request = MagicMock(return_value=MockResponse(status=401))
with (
patch.object(type(coresys.homeassistant.api), "use_unix_socket", True),
patch.object(
type(coresys.homeassistant.api),
"session",
new_callable=PropertyMock,
return_value=websession,
),
patch.object(coresys.homeassistant.api, "check_api_state", return_value=True),
):
resp = await api_client.post(
"/auth", json={"username": "test", "password": "pass"}
)
assert resp.status == 500
body = await resp.json()
assert body["error_key"] == "home_assistant_auth_error"
# pylint: disable-next=protected-access
await coresys.auth._update_cache("test", "pass")
resp = await api_client.post(
"/auth", json={"username": "test", "password": "pass"}
)
assert resp.status == 200
# Let the background backend check run and fail
await asyncio.sleep(0.1)
# pylint: disable-next=protected-access
assert coresys.auth._check_cache("test", "pass") is True
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_auth_json_empty_body(api_client: TestClient, install_app_ssh: App):
"""Test JSON auth with empty body."""
+74 -2
View File
@@ -1,7 +1,7 @@
"""Test Home Assistant API."""
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from awesomeversion import AwesomeVersion
import pytest
@@ -9,7 +9,11 @@ import pytest
from supervisor.coresys import CoreSys
from supervisor.docker.const import ContainerState
from supervisor.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import DockerError, HomeAssistantAPIError
from supervisor.exceptions import (
DockerError,
HomeAssistantAPIError,
HomeAssistantAuthError,
)
from supervisor.homeassistant.api import APIState, CoreHTTPConfig, HomeAssistantAPI
from supervisor.homeassistant.const import LANDINGPAGE
@@ -541,6 +545,74 @@ async def test_make_request_tcp_timeout(coresys: CoreSys):
pass
@pytest.mark.usefixtures("websession")
async def test_make_request_tcp_401_refreshes_token_once(coresys: CoreSys):
"""Test a 401 over TCP drops the token and retries once."""
api = coresys.homeassistant.api
api._access_token = "stale" # pylint: disable=protected-access
coresys.websession.request = MagicMock(
side_effect=[MockResponse(status=401), MockResponse(status=200)]
)
with (
patch.object(type(api), "use_unix_socket", False),
patch.object(api, "_ensure_access_token", new_callable=AsyncMock) as ensure,
):
async with api.make_request("get", "api/test") as resp:
assert resp.status == 200
assert coresys.websession.request.call_count == 2
assert ensure.await_count == 2
@pytest.mark.usefixtures("websession")
async def test_make_request_tcp_401_after_refresh_raises(
coresys: CoreSys, caplog: pytest.LogCaptureFixture
):
"""Test a 401 with a freshly refreshed token raises HomeAssistantAuthError."""
api = coresys.homeassistant.api
coresys.websession.request = MagicMock(
side_effect=[MockResponse(status=401), MockResponse(status=401)]
)
with (
patch.object(type(api), "use_unix_socket", False),
patch.object(api, "_ensure_access_token", new_callable=AsyncMock),
pytest.raises(HomeAssistantAuthError),
):
async with api.make_request("get", "api/test"):
pass
assert coresys.websession.request.call_count == 2
assert (
"Home Assistant rejected Supervisor credentials on api/test "
"after token refresh" in caplog.text
)
@pytest.mark.usefixtures("websession")
async def test_make_request_unix_socket_401_raises(
coresys: CoreSys, caplog: pytest.LogCaptureFixture
):
"""Test a 401 over the Unix socket raises HomeAssistantAuthError right away."""
api = coresys.homeassistant.api
session = MagicMock()
session.request = MagicMock(return_value=MockResponse(status=401))
with (
patch.object(type(api), "use_unix_socket", True),
patch.object(
type(api), "session", new_callable=PropertyMock, return_value=session
),
pytest.raises(HomeAssistantAuthError),
):
async with api.make_request("get", "api/test"):
pass
session.request.assert_called_once()
assert "Home Assistant rejected Supervisor credentials on api/test" in caplog.text
# --- connect_websocket ---