Add support to Google Calendar for Web auth credentials (#103570)

* Add support to Google Calendar for webauth credentials

* Fix broken import

* Fix credential name used on import in test

* Remove unnecessary creds domain parameters

* Remove unnecessary guard to improve code coverage

* Clarify comments about credential preferences

* Fix typo

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2023-11-10 22:49:10 -08:00 committed by GitHub
parent eaaca3e556
commit 3f70437888
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 307 additions and 25 deletions

View file

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

View file

@ -9,7 +9,7 @@ from homeassistant.components.application_credentials import (
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
@ -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]:

View file

@ -18,13 +18,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
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__)
@ -32,7 +40,31 @@ _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
@ -41,12 +73,24 @@ class OAuth2FlowHandler(
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():
@ -68,12 +112,15 @@ class OAuth2FlowHandler(
# 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,
@ -94,6 +141,10 @@ class OAuth2FlowHandler(
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")
@ -125,12 +176,15 @@ class OAuth2FlowHandler(
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
@ -170,6 +224,7 @@ class OAuth2FlowHandler(
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(

View file

@ -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"
@ -32,6 +32,13 @@ class FeatureAccess(Enum):
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"

View file

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

View file

@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
import datetime
from http import HTTPStatus
from typing import Any
from unittest.mock import Mock, patch
from aiohttp.client_exceptions import ClientError
@ -21,9 +22,14 @@ from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.google.const import DOMAIN
from homeassistant.components.google.const import (
CONF_CREDENTIAL_TYPE,
DOMAIN,
CredentialType,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
@ -31,9 +37,13 @@ from homeassistant.util.dt import utcnow
from .conftest import CLIENT_ID, CLIENT_SECRET, EMAIL_ADDRESS, YieldFixture
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
CODE_CHECK_INTERVAL = 1
CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2)
OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth"
OAUTH2_TOKEN = "https://oauth2.googleapis.com/token"
@pytest.fixture(autouse=True)
@ -175,6 +185,7 @@ async def test_full_flow_application_creds(
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
"credential_type": "device_auth",
}
assert result.get("options") == {"calendar_access": "read_write"}
@ -230,7 +241,9 @@ async def test_expired_after_exchange(
) -> None:
"""Test credential exchange expires."""
await async_import_client_credential(
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred"
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
result = await hass.config_entries.flow.async_init(
@ -262,7 +275,9 @@ async def test_exchange_error(
) -> None:
"""Test an error while exchanging the code for credentials."""
await async_import_client_credential(
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth"
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
result = await hass.config_entries.flow.async_init(
@ -307,13 +322,14 @@ async def test_exchange_error(
data["token"].pop("expires_at")
data["token"].pop("expires_in")
assert data == {
"auth_implementation": "device_auth",
"auth_implementation": DOMAIN,
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
"credential_type": "device_auth",
}
assert len(mock_setup.mock_calls) == 1
@ -329,7 +345,7 @@ async def test_duplicate_config_entries(
) -> None:
"""Test that the same account cannot be setup twice."""
await async_import_client_credential(
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred"
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET)
)
# Load a config entry
@ -371,7 +387,7 @@ async def test_multiple_config_entries(
) -> None:
"""Test that multiple config entries can be set at once."""
await async_import_client_credential(
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred"
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET)
)
# Load a config entry
@ -455,17 +471,19 @@ async def test_reauth_flow(
mock_code_flow: Mock,
mock_exchange: Mock,
) -> None:
"""Test can't configure when config entry already exists."""
"""Test reauth of an existing config entry."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": "device_auth",
"auth_implementation": DOMAIN,
"token": {"access_token": "OLD_ACCESS_TOKEN"},
},
)
config_entry.add_to_hass(hass)
await async_import_client_credential(
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth"
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
entries = hass.config_entries.async_entries(DOMAIN)
@ -512,13 +530,14 @@ async def test_reauth_flow(
data["token"].pop("expires_at")
data["token"].pop("expires_in")
assert data == {
"auth_implementation": "device_auth",
"auth_implementation": DOMAIN,
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
"credential_type": "device_auth",
}
assert len(mock_setup.mock_calls) == 1
@ -540,7 +559,9 @@ async def test_calendar_lookup_failure(
) -> None:
"""Test successful config flow and title fetch fails gracefully."""
await async_import_client_credential(
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth"
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
result = await hass.config_entries.flow.async_init(
@ -624,3 +645,189 @@ async def test_options_flow_no_changes(
)
assert result["type"] == "create_entry"
assert config_entry.options == {"calendar_access": "read_write"}
async def test_web_auth_compatibility(
hass: HomeAssistant,
current_request_with_host: None,
mock_code_flow: Mock,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test that we can callback to web auth tokens."""
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
with patch(
"homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes",
side_effect=OAuth2DeviceCodeError(
"Invalid response 401. Error: invalid_client"
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["type"] == "external"
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
"&scope=https://www.googleapis.com/auth/calendar"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"scope": "https://www.googleapis.com/auth/calendar",
},
)
with patch(
"homeassistant.components.google.async_setup_entry", return_value=True
) as mock_setup:
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.CREATE_ENTRY
token = result.get("data", {}).get("token", {})
del token["expires_at"]
assert token == {
"access_token": "mock-access-token",
"expires_in": 60,
"refresh_token": "mock-refresh-token",
"type": "Bearer",
"scope": "https://www.googleapis.com/auth/calendar",
}
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
@pytest.mark.parametrize(
"entry_data",
[
{},
{CONF_CREDENTIAL_TYPE: CredentialType.WEB_AUTH},
],
)
async def test_web_reauth_flow(
hass: HomeAssistant,
mock_code_flow: Mock,
mock_exchange: Mock,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
entry_data: dict[str, Any],
) -> None:
"""Test reauth of an existing config entry with a web credential."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
**entry_data,
"auth_implementation": DOMAIN,
"token": {"access_token": "OLD_ACCESS_TOKEN"},
},
)
config_entry.add_to_hass(hass)
await async_import_client_credential(
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET)
)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": config_entry.entry_id,
},
data=config_entry.data,
)
assert result["type"] == "form"
assert result["step_id"] == "reauth_confirm"
with patch(
"homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes",
side_effect=OAuth2DeviceCodeError(
"Invalid response 401. Error: invalid_client"
),
):
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"],
user_input={},
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result.get("type") == "external"
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
"&scope=https://www.googleapis.com/auth/calendar"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"token_type": "Bearer",
"expires_in": 60,
"scope": "https://www.googleapis.com/auth/calendar",
},
)
with patch(
"homeassistant.components.google.async_setup_entry", return_value=True
) as mock_setup:
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
data = dict(entries[0].data)
data["token"].pop("expires_at")
data["token"].pop("expires_in")
assert data == {
"auth_implementation": DOMAIN,
"token": {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
"credential_type": "web_auth",
}
assert len(mock_setup.mock_calls) == 1