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 all 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
15 changes: 13 additions & 2 deletions 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 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(
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
4 changes: 2 additions & 2 deletions homeassistant/components/google/application_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
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
Expand All @@ -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]:
Expand Down
67 changes: 61 additions & 6 deletions homeassistant/components/google/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,53 @@
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__)


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

Expand All @@ -41,12 +73,24 @@ def __init__(self) -> None:
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():
Expand All @@ -68,12 +112,15 @@ 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")

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,
Expand All @@ -94,6 +141,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 +176,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 +224,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