From 32e4046435428567cba74c8dbc615e339f33cf70 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 12 May 2022 19:33:52 -0700 Subject: [PATCH] Prepare google calendar integration for Application Credentials (#71748) * Prepare google calendar integration for Application Credentials Update google calendar integration to have fewer dependencies on yaml configuration data to prepare for supporting application credentials, which means setup can happen without configuration.yaml at all. This pre-factoring will allow the following PR adding application credentials support to be more focused. * Add test coverage for device auth checks --- homeassistant/components/google/__init__.py | 33 ++++++++++------- homeassistant/components/google/api.py | 35 ++++++++++++++----- .../components/google/config_flow.py | 28 ++++++++++++--- homeassistant/components/google/const.py | 3 ++ tests/components/google/test_config_flow.py | 29 ++++++++++++++- 5 files changed, 103 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 98eadead101..c5f62c0034f 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -40,7 +40,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.typing import ConfigType from . import config_flow -from .api import ApiAuthImpl, DeviceAuth +from .api import ApiAuthImpl, DeviceAuth, get_feature_access from .const import ( CONF_CALENDAR_ACCESS, DATA_CONFIG, @@ -172,7 +172,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # a ConfigEntry managed by home assistant. storage = Storage(hass.config.path(TOKEN_FILE)) creds = await hass.async_add_executor_job(storage.get) - if creds and conf[CONF_CALENDAR_ACCESS].scope in creds.scopes: + if creds and get_feature_access(hass).scope in creds.scopes: _LOGGER.debug("Importing configuration entry with credentials") hass.async_create_task( hass.config_entries.flow.async_init( @@ -210,8 +210,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except aiohttp.ClientError as err: raise ConfigEntryNotReady from err - required_scope = hass.data[DOMAIN][DATA_CONFIG][CONF_CALENDAR_ACCESS].scope - if required_scope not in session.token.get("scope", []): + access = get_feature_access(hass) + if access.scope not in session.token.get("scope", []): raise ConfigEntryAuthFailed( "Required scopes are not available, reauth required" ) @@ -220,7 +220,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hass.data[DOMAIN][DATA_SERVICE] = calendar_service - await async_setup_services(hass, hass.data[DOMAIN][DATA_CONFIG], calendar_service) + track_new = hass.data[DOMAIN][DATA_CONFIG].get(CONF_TRACK_NEW, True) + await async_setup_services(hass, track_new, calendar_service) + # Only expose the add event service if we have the correct permissions + if access is FeatureAccess.read_write: + await async_setup_add_event_service(hass, calendar_service) hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -234,7 +238,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_services( hass: HomeAssistant, - config: ConfigType, + track_new: bool, calendar_service: GoogleCalendarService, ) -> None: """Set up the service listeners.""" @@ -274,7 +278,7 @@ async def async_setup_services( tasks = [] for calendar_item in result.items: calendar = calendar_item.dict(exclude_unset=True) - calendar[CONF_TRACK] = config[CONF_TRACK_NEW] + calendar[CONF_TRACK] = track_new tasks.append( hass.services.async_call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar) ) @@ -282,6 +286,13 @@ async def async_setup_services( hass.services.async_register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars) + +async def async_setup_add_event_service( + hass: HomeAssistant, + calendar_service: GoogleCalendarService, +) -> None: + """Add the service to add events.""" + async def _add_event(call: ServiceCall) -> None: """Add a new event to calendar.""" start: DateOrDatetime | None = None @@ -333,11 +344,9 @@ async def async_setup_services( ), ) - # Only expose the add event service if we have the correct permissions - if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write: - hass.services.async_register( - DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA - ) + hass.services.async_register( + DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA + ) def get_calendar_info( diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index 5de563393e1..bb32d46f0e4 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -19,18 +19,25 @@ from oauth2client.client import ( OAuth2WebServerFlow, ) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import dt -from .const import CONF_CALENDAR_ACCESS, DATA_CONFIG, DEVICE_AUTH_IMPL, DOMAIN +from .const import ( + CONF_CALENDAR_ACCESS, + DATA_CONFIG, + DEFAULT_FEATURE_ACCESS, + DEVICE_AUTH_IMPL, + DOMAIN, + FeatureAccess, +) _LOGGER = logging.getLogger(__name__) EVENT_PAGE_SIZE = 100 EXCHANGE_TIMEOUT_SECONDS = 60 +DEVICE_AUTH_CREDS = "creds" class OAuthError(Exception): @@ -53,7 +60,7 @@ class DeviceAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve a Google API Credentials object to Home Assistant token.""" - creds: Credentials = external_data["creds"] + creds: Credentials = external_data[DEVICE_AUTH_CREDS] return { "access_token": creds.access_token, "refresh_token": creds.refresh_token, @@ -132,13 +139,25 @@ class DeviceFlow: ) -async def async_create_device_flow(hass: HomeAssistant) -> DeviceFlow: +def get_feature_access(hass: HomeAssistant) -> FeatureAccess: + """Return the desired calendar feature access.""" + # This may be called during config entry setup without integration setup running when there + # is no google entry in configuration.yaml + return ( + hass.data.get(DOMAIN, {}) + .get(DATA_CONFIG, {}) + .get(CONF_CALENDAR_ACCESS, DEFAULT_FEATURE_ACCESS) + ) + + +async def async_create_device_flow( + hass: HomeAssistant, client_id: str, client_secret: str, access: FeatureAccess +) -> DeviceFlow: """Create a new Device flow.""" - conf = hass.data[DOMAIN][DATA_CONFIG] oauth_flow = OAuth2WebServerFlow( - client_id=conf[CONF_CLIENT_ID], - client_secret=conf[CONF_CLIENT_SECRET], - scope=conf[CONF_CALENDAR_ACCESS].scope, + client_id=client_id, + client_secret=client_secret, + scope=access.scope, redirect_uri="", ) try: diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index 8bbd2a6c2b1..f5e567a5272 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -9,7 +9,14 @@ from oauth2client.client import Credentials from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow -from .api import DeviceFlow, OAuthError, async_create_device_flow +from .api import ( + DEVICE_AUTH_CREDS, + DeviceAuth, + DeviceFlow, + OAuthError, + async_create_device_flow, + get_feature_access, +) from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -67,15 +74,28 @@ class OAuth2FlowHandler( if not self._device_flow: _LOGGER.debug("Creating DeviceAuth flow") + if not isinstance(self.flow_impl, DeviceAuth): + _LOGGER.error( + "Unexpected OAuth implementation does not support device auth: %s", + self.flow_impl, + ) + return self.async_abort(reason="oauth_error") try: - device_flow = await async_create_device_flow(self.hass) + device_flow = await async_create_device_flow( + self.hass, + self.flow_impl.client_id, + self.flow_impl.client_secret, + get_feature_access(self.hass), + ) except OAuthError as err: _LOGGER.error("Error initializing device flow: %s", str(err)) return self.async_abort(reason="oauth_error") self._device_flow = device_flow async def _exchange_finished(creds: Credentials | None) -> None: - self.external_data = {"creds": creds} # is None on timeout/expiration + self.external_data = { + DEVICE_AUTH_CREDS: creds + } # is None on timeout/expiration self.hass.async_create_task( self.hass.config_entries.flow.async_configure( flow_id=self.flow_id, user_input={} @@ -97,7 +117,7 @@ class OAuth2FlowHandler( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle external yaml configuration.""" - if self.external_data.get("creds") is None: + if self.external_data.get(DEVICE_AUTH_CREDS) is None: return self.async_abort(reason="code_expired") return await super().async_step_creation(user_input) diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index d5cdabb0638..c01ff1ea48b 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -28,3 +28,6 @@ class FeatureAccess(Enum): def scope(self) -> str: """Google calendar scope for the feature.""" return self._scope + + +DEFAULT_FEATURE_ACCESS = FeatureAccess.read_write diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index e96a4c3fd5f..0991f4e5194 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -13,6 +13,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.google.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.dt import utcnow from .conftest import ComponentSetup, YieldFixture @@ -254,7 +255,7 @@ async def test_existing_config_entry( async def test_missing_configuration( hass: HomeAssistant, ) -> None: - """Test can't configure when config entry already exists.""" + """Test can't configure when no authentication source is available.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -262,6 +263,32 @@ async def test_missing_configuration( assert result.get("reason") == "missing_configuration" +async def test_wrong_configuration( + hass: HomeAssistant, +) -> None: + """Test can't use the wrong type of authentication.""" + + # Google calendar flow currently only supports device auth + config_entry_oauth2_flow.async_register_implementation( + hass, + DOMAIN, + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + "client-id", + "client-secret", + "http://example/authorize", + "http://example/token", + ), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "abort" + assert result.get("reason") == "oauth_error" + + async def test_import_config_entry_from_existing_token( hass: HomeAssistant, mock_token_read: None,