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
This commit is contained in:
Allen Porter 2022-05-12 19:33:52 -07:00 committed by GitHub
parent 7ab4960b1e
commit 32e4046435
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 103 additions and 25 deletions

View file

@ -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(

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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,