diff --git a/supervisor/auth.py b/supervisor/auth.py index 828c644f4..1931ad29f 100644 --- a/supervisor/auth.py +++ b/supervisor/auth.py @@ -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) diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index e7f2a17c1..cdd3352e3 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -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): diff --git a/supervisor/homeassistant/api.py b/supervisor/homeassistant/api.py index a7e3efe7e..a43a061fa 100644 --- a/supervisor/homeassistant/api.py +++ b/supervisor/homeassistant/api.py @@ -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: diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index 9cc459f4d..47478e0e4 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -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.""" diff --git a/tests/homeassistant/test_api.py b/tests/homeassistant/test_api.py index 836808280..deb052c47 100644 --- a/tests/homeassistant/test_api.py +++ b/tests/homeassistant/test_api.py @@ -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 ---