Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to Google Calendar for Web auth credentials #103570

Merged
merged 7 commits into from
Nov 11, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 12 additions & 1 deletion homeassistant/components/google/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,18 @@ class OAuthError(Exception):
"""OAuth related error."""


class InvalidCredential(OAuthError):
"""Error with an invalid credential that does not support device auth."""


class DeviceAuth(AuthImplementation):
"""OAuth implementation for Device Auth."""
"""OAuth implementation for Device Auth with callback to Web Auth."""
allenporter marked this conversation as resolved.
Show resolved Hide resolved

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(
Expand Down Expand Up @@ -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)

Expand Down
37 changes: 35 additions & 2 deletions homeassistant/components/google/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,19 @@
AccessTokenAuthImpl,
DeviceAuth,
DeviceFlow,
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__)

Expand All @@ -41,12 +49,26 @@ def __init__(self) -> None:
super().__init__()
self._reauth_config_entry: config_entries.ConfigEntry | None = None
self._device_flow: DeviceFlow | None = None
# We first attempt using device auth (which was recommended) with a
# fallback to web auth. We now prefer web auth since the credentials
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
# can be shared across Google integrations.
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():
Expand All @@ -68,6 +90,9 @@ async def async_step_auth(
# 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")

Expand All @@ -94,6 +119,10 @@ async def async_step_auth(
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")
Expand Down Expand Up @@ -125,12 +154,15 @@ async def async_step_creation(
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
Expand Down Expand Up @@ -170,6 +202,7 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
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(
Expand Down
11 changes: 9 additions & 2 deletions homeassistant/components/google/const.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -32,6 +32,13 @@ def scope(self) -> str:
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"
Expand Down
6 changes: 4 additions & 2 deletions tests/components/google/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down