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:
parent
7ab4960b1e
commit
32e4046435
5 changed files with 103 additions and 25 deletions
|
@ -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,8 +344,6 @@ 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
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue