diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index b915f01ccbe0..78ceec20a2fb 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -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}." }, diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 982cc0a4884f..46bc93934490 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -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() diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 2b4b5056dfbe..5c21c20346ae 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -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.""" diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 163cf7983c49..ace26466c451 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -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 diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index e25e0d82c9b2..1b2b6c53cb85 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -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 diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 88acf0a2d5f1..af0114ce5545 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -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"