Central OAuth error handling (#180394)

This commit is contained in:
Josef Zweck
2026-08-28 21:32:12 +02:00
committed by GitHub
parent fec5158cbf
commit 5a80374d99
6 changed files with 493 additions and 30 deletions
@@ -49,6 +49,12 @@
"oauth2_helper_refresh_transient": {
"message": "Temporary error refreshing credentials for {domain}, try again later"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"oauth2_unknown_implementation": {
"message": "The configured authentication method is no longer available, re-authentication required"
},
"platform_component_load_err": {
"message": "Platform error: {domain} - {error}."
},
+8 -4
View File
@@ -14,7 +14,7 @@ from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, UnknownImplementationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import (
AbstractOAuth2FlowHandler,
@@ -360,9 +360,13 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
# doesn't restrict the connection handshake itself) and proceed directly to OAuth discovery.
return await self.async_step_auth_discovery()
self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment]
self.hass, config_entry
)
try:
self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment]
self.hass, config_entry
)
except UnknownImplementationError:
# The credentials were removed, let the user pick or create new ones
return await self.async_step_auth_discovery()
return await self.async_step_auth()
+41 -4
View File
@@ -283,8 +283,12 @@ class OAuth2TokenRequestError(ClientResponseError, HomeAssistantError):
self.generate_message = True
class OAuth2TokenRequestTransientError(OAuth2TokenRequestError):
"""Recoverable error to indicate flow could not refresh token."""
class OAuth2TokenRequestTransientError(OAuth2TokenRequestError, ConfigEntryNotReady):
"""Recoverable error to indicate flow could not refresh token.
Inherits ConfigEntryNotReady so setup retries without the integration having to
map it. Catch it explicitly to handle it differently.
"""
def __init__(self, *, domain: str, **kwargs: Any) -> None:
"""Initialize OAuth2RefreshTokenTransientError."""
@@ -295,10 +299,11 @@ class OAuth2TokenRequestTransientError(OAuth2TokenRequestError):
self.generate_message = True
class OAuth2TokenRequestReauthError(OAuth2TokenRequestError):
class OAuth2TokenRequestReauthError(OAuth2TokenRequestError, ConfigEntryAuthFailed):
"""Non recoverable error to indicate the flow could not refresh token.
Re-authentication is required.
Inherits ConfigEntryAuthFailed so setup starts reauth without the integration
having to map it. Catch it explicitly to handle it differently.
"""
def __init__(self, *, domain: str, **kwargs: Any) -> None:
@@ -310,6 +315,38 @@ class OAuth2TokenRequestReauthError(OAuth2TokenRequestError):
self.generate_message = True
class ImplementationUnavailableError(ConfigEntryNotReady):
"""Raised when an underlying OAuth 2.0 implementation is unavailable.
Inherits ConfigEntryNotReady so setup retries without the integration having to
map it. Catch it explicitly to handle it differently.
"""
def __init__(self, *args: object) -> None:
"""Initialize the error."""
super().__init__(
*args,
translation_domain="homeassistant",
translation_key="oauth2_implementation_unavailable",
)
class UnknownImplementationError(ConfigEntryAuthFailed, ValueError):
"""Raised when a config entry references an implementation that is not registered.
Also a ValueError so callers catching that keep working. Inherits
ConfigEntryAuthFailed because the user has to link the account again.
"""
def __init__(self, *args: object) -> None:
"""Initialize the error."""
super().__init__(
*args,
translation_domain="homeassistant",
translation_key="oauth2_unknown_implementation",
)
class InvalidStateError(HomeAssistantError):
"""When an invalid state is encountered."""
@@ -29,10 +29,11 @@ from yarl import URL
from homeassistant import config_entries
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.exceptions import (
HomeAssistantError,
ImplementationUnavailableError,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
OAuth2TokenRequestTransientError,
UnknownImplementationError,
)
from homeassistant.loader import async_get_application_credentials
from homeassistant.util.hass_dict import HassKey
@@ -46,6 +47,27 @@ from .service_info.zeroconf import ZeroconfServiceInfo
_LOGGER = logging.getLogger(__name__)
__all__ = [
"AUTH_CALLBACK_PATH",
"HEADER_FRONTEND_BASE",
"MY_AUTH_CALLBACK_PATH",
"AbstractOAuth2FlowHandler",
"AbstractOAuth2Implementation",
# Re-exported since integrations imported it from here before it moved
# to homeassistant.exceptions.
"ImplementationUnavailableError",
"LocalOAuth2Implementation",
"LocalOAuth2ImplementationWithPkce",
"OAuth2AuthorizeCallbackView",
"OAuth2Session",
"async_add_implementation_provider",
"async_get_config_entry_implementation",
"async_get_implementations",
"async_get_redirect_uri",
"async_oauth2_request",
"async_register_implementation",
]
DATA_JWT_SECRET = "oauth2_jwt_secret"
DATA_IMPLEMENTATIONS: HassKey[dict[str, dict[str, AbstractOAuth2Implementation]]] = (
HassKey("oauth2_impl")
@@ -83,10 +105,6 @@ _SHARED_ABORT_REASONS = frozenset(
)
class ImplementationUnavailableError(HomeAssistantError):
"""Raised when an underlying implementation is unavailable."""
@callback
def async_get_redirect_uri(hass: HomeAssistant) -> str:
"""Return the redirect uri."""
@@ -485,8 +503,14 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
return self.async_abort(reason="oauth_implementation_unavailable")
if user_input is not None:
self.flow_impl = implementations[user_input["implementation"]]
return await self.async_step_auth()
# Reauth and reconfigure steps pass the stored implementation, which is
# gone when its credentials were removed. Fall through to let the user
# pick or create credentials instead of failing the flow.
if (
implementation := implementations.get(user_input["implementation"])
) is not None:
self.flow_impl = implementation
return await self.async_step_auth()
if not implementations:
if self.DOMAIN in await async_get_application_credentials(self.hass):
@@ -666,24 +690,31 @@ def async_register_implementation(
implementations.setdefault(domain, {})[implementation.domain] = implementation
async def async_get_implementations(
async def _async_get_implementations(
hass: HomeAssistant, domain: str
) -> dict[str, AbstractOAuth2Implementation]:
"""Return OAuth2 implementations for specified domain."""
registered = hass.data.setdefault(DATA_IMPLEMENTATIONS, {}).get(domain, {})
) -> tuple[
dict[str, AbstractOAuth2Implementation], list[ImplementationUnavailableError]
]:
"""Return OAuth2 implementations for specified domain and any provider failures."""
registered = dict(hass.data.setdefault(DATA_IMPLEMENTATIONS, {}).get(domain, {}))
exceptions: list[ImplementationUnavailableError] = []
if DATA_PROVIDERS not in hass.data:
return registered
registered = dict(registered)
exceptions = []
for get_impl in list(hass.data[DATA_PROVIDERS].values()):
for get_impl in list(hass.data.get(DATA_PROVIDERS, {}).values()):
try:
for impl in await get_impl(hass, domain):
registered[impl.domain] = impl
except ImplementationUnavailableError as err:
exceptions.append(err)
return registered, exceptions
async def async_get_implementations(
hass: HomeAssistant, domain: str
) -> dict[str, AbstractOAuth2Implementation]:
"""Return OAuth2 implementations for specified domain."""
registered, exceptions = await _async_get_implementations(hass, domain)
if not registered and exceptions:
raise ImplementationUnavailableError(*exceptions)
@@ -694,13 +725,20 @@ async def async_get_config_entry_implementation(
hass: HomeAssistant, config_entry: config_entries.ConfigEntry
) -> AbstractOAuth2Implementation:
"""Return the implementation for this config entry."""
implementations = await async_get_implementations(hass, config_entry.domain)
implementations, exceptions = await _async_get_implementations(
hass, config_entry.domain
)
implementation = implementations.get(config_entry.data["auth_implementation"])
if implementation is None:
raise ValueError("Implementation not available")
if implementation is not None:
return implementation
return implementation
if exceptions:
# A provider is down, so the configured implementation may still come back.
# Retry instead of asking the user to link the account again.
raise ImplementationUnavailableError(*exceptions)
raise UnknownImplementationError
@callback
+70
View File
@@ -1025,3 +1025,73 @@ async def test_reauth_flow_upgrade_to_oauth_no_auth_header(
# Flow should proceed directly to credentials choice menu (without validate_input)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "credentials_choice"
@pytest.mark.usefixtures("current_request_with_host")
@respx.mock
async def test_reauth_flow_missing_implementation(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mcp_client: Mock,
credential: None,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test reauth recovers when the stored implementation was removed."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": "removed",
CONF_URL: MCP_SERVER_URL,
CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL,
CONF_TOKEN_URL: OAUTH_TOKEN_URL,
},
title=TEST_API_NAME,
)
config_entry.add_to_hass(hass)
config_entry.async_start_reauth(hass)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "reauth_confirm"
respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(
return_value=OAUTH_SERVER_METADATA_RESPONSE
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
# Instead of erroring out, the user can pick or create credentials again
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "credentials_choice"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "pick_implementation"},
)
assert result["type"] is FlowResultType.EXTERNAL_STEP
result = await perform_oauth_flow(
hass,
aioclient_mock,
hass_client_no_auth,
result,
authorize_url=OAUTH_AUTHORIZE_URL,
token_url=OAUTH_TOKEN_URL,
scopes=SCOPES,
)
response = Mock()
response.serverInfo.name = TEST_API_NAME
mock_mcp_client.return_value.initialize.return_value = response
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
# The entry now points at an implementation that exists again
assert config_entry.data["auth_implementation"] == AUTH_DOMAIN
assert config_entry.data[CONF_TOKEN]
assert len(mock_setup_entry.mock_calls) == 1
+309 -1
View File
@@ -14,6 +14,8 @@ import pytest
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
OAuth2TokenRequestTransientError,
@@ -1479,7 +1481,7 @@ async def test_async_get_config_entry_implementation_missing_provider(
)
# This should fail since both providers are empty.
with pytest.raises(ValueError, match="Implementation not available"):
with pytest.raises(ValueError, match="no longer available"):
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, config_entry
)
@@ -1513,3 +1515,309 @@ async def test_oauth2_request_replaces_caller_authorization_header(
# The token must not be sent as a second Authorization header
assert headers.getall("Authorization") == [f"Bearer {ACCESS_TOKEN_1}"]
@pytest.mark.parametrize(
("status_code", "expected_state", "expected_translation_key"),
[
pytest.param(
HTTPStatus.BAD_REQUEST,
config_entries.ConfigEntryState.SETUP_ERROR,
"oauth2_helper_reauth_required",
id="reauth",
),
pytest.param(
HTTPStatus.TOO_MANY_REQUESTS,
config_entries.ConfigEntryState.SETUP_RETRY,
"oauth2_helper_refresh_transient",
id="transient",
),
pytest.param(
600,
config_entries.ConfigEntryState.SETUP_ERROR,
None,
id="generic",
),
],
)
@pytest.mark.usefixtures("flow_handler")
async def test_token_error_handled_without_integration_mapping(
hass: HomeAssistant,
local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation,
aioclient_mock: AiohttpClientMocker,
status_code: int,
expected_state: config_entries.ConfigEntryState,
expected_translation_key: str | None,
) -> None:
"""Test setup maps token refresh errors when the integration does not.
Only the transient and reauth subclasses carry config entry semantics, the
base error is left to the integration.
"""
aioclient_mock.post(TOKEN_URL, status=status_code, json={})
config_entry = MockConfigEntry(
domain=TEST_DOMAIN,
data={
"auth_implementation": TEST_DOMAIN,
"token": {
"refresh_token": REFRESH_TOKEN,
"access_token": ACCESS_TOKEN_1,
"expires_at": 0,
},
},
)
config_entry.add_to_hass(hass)
async def async_setup_entry(
hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> bool:
"""Refresh the token without mapping the OAuth errors."""
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, local_impl)
await session.async_ensure_token_valid()
return True
mock_integration(hass, MockModule(TEST_DOMAIN, async_setup_entry=async_setup_entry))
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is expected_state
assert config_entry.error_reason_translation_key == expected_translation_key
@pytest.mark.usefixtures("flow_handler")
async def test_token_error_integration_can_handle_it_itself(
hass: HomeAssistant,
local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test an integration can still map a token error to its own behaviour."""
aioclient_mock.post(TOKEN_URL, status=HTTPStatus.BAD_REQUEST, json={})
config_entry = MockConfigEntry(
domain=TEST_DOMAIN,
data={
"auth_implementation": TEST_DOMAIN,
"token": {
"refresh_token": REFRESH_TOKEN,
"access_token": ACCESS_TOKEN_1,
"expires_at": 0,
},
},
)
config_entry.add_to_hass(hass)
async def async_setup_entry(
hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> bool:
"""Treat a reauth error as recoverable instead."""
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, local_impl)
try:
await session.async_ensure_token_valid()
except OAuth2TokenRequestReauthError as err:
raise ConfigEntryNotReady from err
return True
mock_integration(hass, MockModule(TEST_DOMAIN, async_setup_entry=async_setup_entry))
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is config_entries.ConfigEntryState.SETUP_RETRY
async def test_unknown_implementation_asks_for_reauth(hass: HomeAssistant) -> None:
"""Test an entry referencing a removed implementation asks for reauth."""
config_entry = MockConfigEntry(
domain=TEST_DOMAIN,
data={"auth_implementation": "removed", "token": {}},
)
config_entry.add_to_hass(hass)
with pytest.raises(ConfigEntryAuthFailed) as err:
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, config_entry
)
# Still a ValueError so integrations catching that keep working
assert isinstance(err.value, ValueError)
@pytest.mark.usefixtures("flow_handler")
async def test_implementation_unavailable_retries_setup(hass: HomeAssistant) -> None:
"""Test an unavailable implementation retries setup without integration mapping."""
async def failing_provider(
hass: HomeAssistant, domain: str
) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]:
"""Fail like the cloud provider does when it cannot reach the server."""
raise config_entry_oauth2_flow.ImplementationUnavailableError("cloud is down")
config_entry_oauth2_flow.async_add_implementation_provider(
hass, "cloud", failing_provider
)
config_entry = MockConfigEntry(
domain=TEST_DOMAIN,
data={"auth_implementation": TEST_DOMAIN, "token": {}},
)
config_entry.add_to_hass(hass)
async def async_setup_entry(
hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> bool:
"""Resolve the implementation without mapping the error."""
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
return True
mock_integration(hass, MockModule(TEST_DOMAIN, async_setup_entry=async_setup_entry))
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is config_entries.ConfigEntryState.SETUP_RETRY
assert (
config_entry.error_reason_translation_key == "oauth2_implementation_unavailable"
)
async def test_config_entry_implementation_unavailable_provider(
hass: HomeAssistant,
local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation,
) -> None:
"""Test a temporarily unavailable provider is not mistaken for a removed one.
The entry is linked through the cloud, which is down, while the integration
still offers a local implementation the user has not configured.
"""
async def failing_cloud_provider(
_hass: HomeAssistant, _domain: str
) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]:
"""Fail like the cloud provider does when it cannot reach the server."""
raise config_entry_oauth2_flow.ImplementationUnavailableError("cloud is down")
config_entry_oauth2_flow.async_add_implementation_provider(
hass, "cloud", failing_cloud_provider
)
# The integration still offers a local implementation, so the cloud failure
# would otherwise be swallowed
config_entry_oauth2_flow.async_register_implementation(
hass, TEST_DOMAIN, local_impl
)
config_entry = MockConfigEntry(
domain=TEST_DOMAIN,
data={"auth_implementation": "cloud"},
)
# Retry rather than asking the user to re-link an account that is fine
with pytest.raises(config_entry_oauth2_flow.ImplementationUnavailableError):
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, config_entry
)
@pytest.mark.usefixtures("current_request_with_host")
async def test_pick_implementation_falls_back_when_removed(
hass: HomeAssistant,
flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler],
local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test a stale implementation id does not break the flow.
Reauth and reconfigure steps pass the implementation stored on the entry, which
is gone once its credentials were removed.
"""
mock_integration(
hass,
MockModule(TEST_DOMAIN, async_setup_entry=AsyncMock(return_value=True)),
)
flow_handler.async_register_implementation(hass, local_impl)
class ReauthFlowHandler(flow_handler):
"""Handler passing the stored implementation, like spotify and watts do."""
async def async_step_reauth(
self, entry_data: dict[str, Any]
) -> config_entries.ConfigFlowResult:
"""Perform reauth with the implementation stored on the entry."""
return await self.async_step_pick_implementation(
user_input={
"implementation": self._get_reauth_entry().data[
"auth_implementation"
]
}
)
async def async_oauth_create_entry(
self, data: dict
) -> config_entries.ConfigFlowResult:
"""Update the existing entry instead of creating a new one."""
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
config_entry = MockConfigEntry(
domain=TEST_DOMAIN,
data={
"auth_implementation": "removed",
"token": {"refresh_token": REFRESH_TOKEN, "expires_at": 0},
},
)
config_entry.add_to_hass(hass)
with patch.dict(config_entries.HANDLERS, {TEST_DOMAIN: ReauthFlowHandler}):
# The stale id falls through to the only implementation left
result = await config_entry.start_reauth_flow(hass)
assert result["type"] is data_entry_flow.FlowResultType.EXTERNAL_STEP
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
aioclient_mock.post(
TOKEN_URL,
json={
"refresh_token": REFRESH_TOKEN,
"access_token": ACCESS_TOKEN_1,
"type": "bearer",
"expires_in": 60,
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
# The entry points at an implementation that exists again
assert config_entry.data["auth_implementation"] == TEST_DOMAIN
assert config_entry.data["token"]["access_token"] == ACCESS_TOKEN_1
async def test_pick_implementation_removed_without_any_left(
hass: HomeAssistant,
flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler],
) -> None:
"""Test a stale implementation id aborts cleanly when nothing is available."""
flow = flow_handler()
flow.hass = hass
result = await flow.async_step_pick_implementation(
user_input={"implementation": "removed"}
)
assert result["type"] is data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "missing_configuration"