mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-10-02 12:38:43 +01:00
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:
co-authored by
Claude Fable 5.1
parent
2cd1fb514f
commit
f91ca71d40
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user