From 3f70437888a19ad0c35fbc93a2943a4b42fc3fb9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 10 Nov 2023 22:49:10 -0800 Subject: [PATCH] Add support to Google Calendar for Web auth credentials (#103570) * Add support to Google Calendar for webauth credentials * Fix broken import * Fix credential name used on import in test * Remove unnecessary creds domain parameters * Remove unnecessary guard to improve code coverage * Clarify comments about credential preferences * Fix typo --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/google/api.py | 15 +- .../google/application_credentials.py | 4 +- .../components/google/config_flow.py | 67 ++++- homeassistant/components/google/const.py | 11 +- tests/components/google/conftest.py | 6 +- tests/components/google/test_config_flow.py | 229 +++++++++++++++++- 6 files changed, 307 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index f37e120db68..8ed18cca41c 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -45,11 +45,18 @@ class OAuthError(Exception): """OAuth related error.""" -class DeviceAuth(AuthImplementation): - """OAuth implementation for Device Auth.""" +class InvalidCredential(OAuthError): + """Error with an invalid credential that does not support device auth.""" + + +class GoogleHybridAuth(AuthImplementation): + """OAuth implementation that supports both Web Auth (base class) and Device Auth.""" async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve a Google API Credentials object to Home Assistant token.""" + if DEVICE_AUTH_CREDS not in external_data: + # Assume the Web Auth flow was used, so use the default behavior + return await super().async_resolve_external_data(external_data) creds: Credentials = external_data[DEVICE_AUTH_CREDS] delta = creds.token_expiry.replace(tzinfo=datetime.UTC) - dt_util.utcnow() _LOGGER.debug( @@ -192,6 +199,10 @@ async def async_create_device_flow( oauth_flow.step1_get_device_and_user_codes ) except OAuth2DeviceCodeError as err: + _LOGGER.debug("OAuth2DeviceCodeError error: %s", err) + # Web auth credentials reply with invalid_client when hitting this endpoint + if "Error: invalid_client" in str(err): + raise InvalidCredential(str(err)) from err raise OAuthError(str(err)) from err return DeviceFlow(hass, oauth_flow, device_flow_info) diff --git a/homeassistant/components/google/application_credentials.py b/homeassistant/components/google/application_credentials.py index 60ad9b3275e..bb1ddfef5d7 100644 --- a/homeassistant/components/google/application_credentials.py +++ b/homeassistant/components/google/application_credentials.py @@ -9,7 +9,7 @@ from homeassistant.components.application_credentials import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from .api import DeviceAuth +from .api import GoogleHybridAuth AUTHORIZATION_SERVER = AuthorizationServer( oauth2client.GOOGLE_AUTH_URI, oauth2client.GOOGLE_TOKEN_URI @@ -20,7 +20,7 @@ async def async_get_auth_implementation( hass: HomeAssistant, auth_domain: str, credential: ClientCredential ) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: """Return auth implementation.""" - return DeviceAuth(hass, auth_domain, credential, AUTHORIZATION_SERVER) + return GoogleHybridAuth(hass, auth_domain, credential, AUTHORIZATION_SERVER) async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index 1945afe15e9..33d913fe8f1 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -18,13 +18,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .api import ( DEVICE_AUTH_CREDS, AccessTokenAuthImpl, - DeviceAuth, DeviceFlow, + GoogleHybridAuth, + InvalidCredential, OAuthError, async_create_device_flow, get_feature_access, ) -from .const import CONF_CALENDAR_ACCESS, DOMAIN, FeatureAccess +from .const import ( + CONF_CALENDAR_ACCESS, + CONF_CREDENTIAL_TYPE, + DEFAULT_FEATURE_ACCESS, + DOMAIN, + CredentialType, + FeatureAccess, +) _LOGGER = logging.getLogger(__name__) @@ -32,7 +40,31 @@ _LOGGER = logging.getLogger(__name__) class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): - """Config flow to handle Google Calendars OAuth2 authentication.""" + """Config flow to handle Google Calendars OAuth2 authentication. + + Historically, the Google Calendar integration instructed users to use + Device Auth. Device Auth was considered easier to use since it did not + require users to configure a redirect URL. Device Auth is meant for + devices with limited input, such as a television. + https://developers.google.com/identity/protocols/oauth2/limited-input-device + + Device Auth is limited to a small set of Google APIs (calendar is allowed) + and is considered less secure than Web Auth. It is not generally preferred + and may be limited/deprecated in the future similar to App/OOB Auth + https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html + + Web Auth is the preferred method by Home Assistant and Google, and a benefit + is that the same credentials may be used across many Google integrations in + Home Assistant. Web Auth is now easier for user to setup using my.home-assistant.io + redirect urls. + + The Application Credentials integration does not currently record which type + of credential the user entered (and if we ask the user, they may not know or may + make a mistake) so we try to determine the credential type automatically. This + implementation first attempts Device Auth by talking to the token API in the first + step of the device flow, then if that fails it will redirect using Web Auth. + There is not another explicit known way to check. + """ DOMAIN = DOMAIN @@ -41,12 +73,24 @@ class OAuth2FlowHandler( super().__init__() self._reauth_config_entry: config_entries.ConfigEntry | None = None self._device_flow: DeviceFlow | None = None + # First attempt is device auth, then fallback to web auth + self._web_auth = False @property def logger(self) -> logging.Logger: """Return logger.""" return logging.getLogger(__name__) + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": DEFAULT_FEATURE_ACCESS.scope, + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + async def async_step_import(self, info: dict[str, Any]) -> FlowResult: """Import existing auth into a new config entry.""" if self._async_current_entries(): @@ -68,12 +112,15 @@ class OAuth2FlowHandler( # prompt the user to visit a URL and enter a code. The device flow # background task will poll the exchange endpoint to get valid # creds or until a timeout is complete. + if self._web_auth: + return await super().async_step_auth(user_input) + if user_input is not None: return self.async_show_progress_done(next_step_id="creation") if not self._device_flow: - _LOGGER.debug("Creating DeviceAuth flow") - if not isinstance(self.flow_impl, DeviceAuth): + _LOGGER.debug("Creating GoogleHybridAuth flow") + if not isinstance(self.flow_impl, GoogleHybridAuth): _LOGGER.error( "Unexpected OAuth implementation does not support device auth: %s", self.flow_impl, @@ -94,6 +141,10 @@ class OAuth2FlowHandler( except TimeoutError as err: _LOGGER.error("Timeout initializing device flow: %s", str(err)) return self.async_abort(reason="timeout_connect") + except InvalidCredential: + _LOGGER.debug("Falling back to Web Auth and restarting flow") + self._web_auth = True + return await super().async_step_auth() except OAuthError as err: _LOGGER.error("Error initializing device flow: %s", str(err)) return self.async_abort(reason="oauth_error") @@ -125,12 +176,15 @@ class OAuth2FlowHandler( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle external yaml configuration.""" - if self.external_data.get(DEVICE_AUTH_CREDS) is None: + if not self._web_auth and self.external_data.get(DEVICE_AUTH_CREDS) is None: return self.async_abort(reason="code_expired") return await super().async_step_creation(user_input) async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow, or update existing entry.""" + data[CONF_CREDENTIAL_TYPE] = ( + CredentialType.WEB_AUTH if self._web_auth else CredentialType.DEVICE_AUTH + ) if self._reauth_config_entry: self.hass.config_entries.async_update_entry( self._reauth_config_entry, data=data @@ -170,6 +224,7 @@ class OAuth2FlowHandler( self._reauth_config_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) + self._web_auth = entry_data.get(CONF_CREDENTIAL_TYPE) == CredentialType.WEB_AUTH return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index add98441e39..6f497543b2d 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -1,12 +1,12 @@ """Constants for google integration.""" from __future__ import annotations -from enum import Enum +from enum import Enum, StrEnum DOMAIN = "google" -DEVICE_AUTH_IMPL = "device_auth" CONF_CALENDAR_ACCESS = "calendar_access" +CONF_CREDENTIAL_TYPE = "credential_type" DATA_CALENDARS = "calendars" DATA_SERVICE = "service" DATA_CONFIG = "config" @@ -32,6 +32,13 @@ class FeatureAccess(Enum): DEFAULT_FEATURE_ACCESS = FeatureAccess.read_write +class CredentialType(StrEnum): + """Type of application credentials used.""" + + DEVICE_AUTH = "device_auth" + WEB_AUTH = "web_auth" + + EVENT_DESCRIPTION = "description" EVENT_END_DATE = "end_date" EVENT_END_DATETIME = "end_date_time" diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index d938a2f3291..3b2ed6d24e1 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -218,7 +218,7 @@ def config_entry( domain=DOMAIN, unique_id=config_entry_unique_id, data={ - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": { "access_token": "ACCESS_TOKEN", "refresh_token": "REFRESH_TOKEN", @@ -350,7 +350,9 @@ def component_setup( async def _setup_func() -> bool: assert await async_setup_component(hass, "application_credentials", {}) await async_import_client_credential( - hass, DOMAIN, ClientCredential("client-id", "client-secret"), "device_auth" + hass, + DOMAIN, + ClientCredential("client-id", "client-secret"), ) config_entry.add_to_hass(hass) return await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index aa8976bda21..f534f624bf6 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable import datetime from http import HTTPStatus +from typing import Any from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError @@ -21,9 +22,14 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.google.const import DOMAIN +from homeassistant.components.google.const import ( + CONF_CREDENTIAL_TYPE, + DOMAIN, + CredentialType, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -31,9 +37,13 @@ from homeassistant.util.dt import utcnow from .conftest import CLIENT_ID, CLIENT_SECRET, EMAIL_ADDRESS, YieldFixture from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator CODE_CHECK_INTERVAL = 1 CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2) +OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" +OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" @pytest.fixture(autouse=True) @@ -175,6 +185,7 @@ async def test_full_flow_application_creds( "scope": "https://www.googleapis.com/auth/calendar", "token_type": "Bearer", }, + "credential_type": "device_auth", } assert result.get("options") == {"calendar_access": "read_write"} @@ -230,7 +241,9 @@ async def test_expired_after_exchange( ) -> None: """Test credential exchange expires.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) result = await hass.config_entries.flow.async_init( @@ -262,7 +275,9 @@ async def test_exchange_error( ) -> None: """Test an error while exchanging the code for credentials.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) result = await hass.config_entries.flow.async_init( @@ -307,13 +322,14 @@ async def test_exchange_error( data["token"].pop("expires_at") data["token"].pop("expires_in") assert data == { - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": { "access_token": "ACCESS_TOKEN", "refresh_token": "REFRESH_TOKEN", "scope": "https://www.googleapis.com/auth/calendar", "token_type": "Bearer", }, + "credential_type": "device_auth", } assert len(mock_setup.mock_calls) == 1 @@ -329,7 +345,7 @@ async def test_duplicate_config_entries( ) -> None: """Test that the same account cannot be setup twice.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) # Load a config entry @@ -371,7 +387,7 @@ async def test_multiple_config_entries( ) -> None: """Test that multiple config entries can be set at once.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) # Load a config entry @@ -455,17 +471,19 @@ async def test_reauth_flow( mock_code_flow: Mock, mock_exchange: Mock, ) -> None: - """Test can't configure when config entry already exists.""" + """Test reauth of an existing config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, data={ - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": {"access_token": "OLD_ACCESS_TOKEN"}, }, ) config_entry.add_to_hass(hass) await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) entries = hass.config_entries.async_entries(DOMAIN) @@ -512,13 +530,14 @@ async def test_reauth_flow( data["token"].pop("expires_at") data["token"].pop("expires_in") assert data == { - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": { "access_token": "ACCESS_TOKEN", "refresh_token": "REFRESH_TOKEN", "scope": "https://www.googleapis.com/auth/calendar", "token_type": "Bearer", }, + "credential_type": "device_auth", } assert len(mock_setup.mock_calls) == 1 @@ -540,7 +559,9 @@ async def test_calendar_lookup_failure( ) -> None: """Test successful config flow and title fetch fails gracefully.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) result = await hass.config_entries.flow.async_init( @@ -624,3 +645,189 @@ async def test_options_flow_no_changes( ) assert result["type"] == "create_entry" assert config_entry.options == {"calendar_access": "read_write"} + + +async def test_web_auth_compatibility( + hass: HomeAssistant, + current_request_with_host: None, + mock_code_flow: Mock, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test that we can callback to web auth tokens.""" + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + with patch( + "homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes", + side_effect=OAuth2DeviceCodeError( + "Invalid response 401. Error: invalid_client" + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["type"] == "external" + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/calendar" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "https://www.googleapis.com/auth/calendar", + }, + ) + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.CREATE_ENTRY + token = result.get("data", {}).get("token", {}) + del token["expires_at"] + assert token == { + "access_token": "mock-access-token", + "expires_in": 60, + "refresh_token": "mock-refresh-token", + "type": "Bearer", + "scope": "https://www.googleapis.com/auth/calendar", + } + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.parametrize( + "entry_data", + [ + {}, + {CONF_CREDENTIAL_TYPE: CredentialType.WEB_AUTH}, + ], +) +async def test_web_reauth_flow( + hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + entry_data: dict[str, Any], +) -> None: + """Test reauth of an existing config entry with a web credential.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + **entry_data, + "auth_implementation": DOMAIN, + "token": {"access_token": "OLD_ACCESS_TOKEN"}, + }, + ) + config_entry.add_to_hass(hass) + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) + ) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes", + side_effect=OAuth2DeviceCodeError( + "Invalid response 401. Error: invalid_client" + ), + ): + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={}, + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result.get("type") == "external" + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/calendar" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 60, + "scope": "https://www.googleapis.com/auth/calendar", + }, + ) + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + data = dict(entries[0].data) + data["token"].pop("expires_at") + data["token"].pop("expires_in") + assert data == { + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "scope": "https://www.googleapis.com/auth/calendar", + "token_type": "Bearer", + }, + "credential_type": "web_auth", + } + + assert len(mock_setup.mock_calls) == 1