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.""" """OAuth related error."""
class DeviceAuth(AuthImplementation): class InvalidCredential(OAuthError):
"""OAuth implementation for Device Auth.""" """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: async def async_resolve_external_data(self, external_data: Any) -> dict:
"""Resolve a Google API Credentials object to Home Assistant token.""" """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] creds: Credentials = external_data[DEVICE_AUTH_CREDS]
delta = creds.token_expiry.replace(tzinfo=datetime.UTC) - dt_util.utcnow() delta = creds.token_expiry.replace(tzinfo=datetime.UTC) - dt_util.utcnow()
_LOGGER.debug( _LOGGER.debug(
@ -192,6 +199,10 @@ async def async_create_device_flow(
oauth_flow.step1_get_device_and_user_codes oauth_flow.step1_get_device_and_user_codes
) )
except OAuth2DeviceCodeError as err: 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 raise OAuthError(str(err)) from err
return DeviceFlow(hass, oauth_flow, device_flow_info) 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.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from .api import DeviceAuth from .api import GoogleHybridAuth
AUTHORIZATION_SERVER = AuthorizationServer( AUTHORIZATION_SERVER = AuthorizationServer(
oauth2client.GOOGLE_AUTH_URI, oauth2client.GOOGLE_TOKEN_URI 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 hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: ) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
"""Return auth implementation.""" """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]: 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 ( from .api import (
DEVICE_AUTH_CREDS, DEVICE_AUTH_CREDS,
AccessTokenAuthImpl, AccessTokenAuthImpl,
DeviceAuth,
DeviceFlow, DeviceFlow,
GoogleHybridAuth,
InvalidCredential,
OAuthError, OAuthError,
async_create_device_flow, async_create_device_flow,
get_feature_access, 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__) _LOGGER = logging.getLogger(__name__)
@ -32,7 +40,31 @@ _LOGGER = logging.getLogger(__name__)
class OAuth2FlowHandler( class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN 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 DOMAIN = DOMAIN
@ -41,12 +73,24 @@ class OAuth2FlowHandler(
super().__init__() super().__init__()
self._reauth_config_entry: config_entries.ConfigEntry | None = None self._reauth_config_entry: config_entries.ConfigEntry | None = None
self._device_flow: DeviceFlow | None = None self._device_flow: DeviceFlow | None = None
# First attempt is device auth, then fallback to web auth
self._web_auth = False
@property @property
def logger(self) -> logging.Logger: def logger(self) -> logging.Logger:
"""Return logger.""" """Return logger."""
return logging.getLogger(__name__) 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: async def async_step_import(self, info: dict[str, Any]) -> FlowResult:
"""Import existing auth into a new config entry.""" """Import existing auth into a new config entry."""
if self._async_current_entries(): 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 # prompt the user to visit a URL and enter a code. The device flow
# background task will poll the exchange endpoint to get valid # background task will poll the exchange endpoint to get valid
# creds or until a timeout is complete. # 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: if user_input is not None:
return self.async_show_progress_done(next_step_id="creation") return self.async_show_progress_done(next_step_id="creation")
if not self._device_flow: if not self._device_flow:
_LOGGER.debug("Creating DeviceAuth flow") _LOGGER.debug("Creating GoogleHybridAuth flow")
if not isinstance(self.flow_impl, DeviceAuth): if not isinstance(self.flow_impl, GoogleHybridAuth):
_LOGGER.error( _LOGGER.error(
"Unexpected OAuth implementation does not support device auth: %s", "Unexpected OAuth implementation does not support device auth: %s",
self.flow_impl, self.flow_impl,
@ -94,6 +141,10 @@ class OAuth2FlowHandler(
except TimeoutError as err: except TimeoutError as err:
_LOGGER.error("Timeout initializing device flow: %s", str(err)) _LOGGER.error("Timeout initializing device flow: %s", str(err))
return self.async_abort(reason="timeout_connect") 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: except OAuthError as err:
_LOGGER.error("Error initializing device flow: %s", str(err)) _LOGGER.error("Error initializing device flow: %s", str(err))
return self.async_abort(reason="oauth_error") return self.async_abort(reason="oauth_error")
@ -125,12 +176,15 @@ class OAuth2FlowHandler(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Handle external yaml configuration.""" """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 self.async_abort(reason="code_expired")
return await super().async_step_creation(user_input) return await super().async_step_creation(user_input)
async def async_oauth_create_entry(self, data: dict) -> FlowResult: async def async_oauth_create_entry(self, data: dict) -> FlowResult:
"""Create an entry for the flow, or update existing entry.""" """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: if self._reauth_config_entry:
self.hass.config_entries.async_update_entry( self.hass.config_entries.async_update_entry(
self._reauth_config_entry, data=data self._reauth_config_entry, data=data
@ -170,6 +224,7 @@ class OAuth2FlowHandler(
self._reauth_config_entry = self.hass.config_entries.async_get_entry( self._reauth_config_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"] self.context["entry_id"]
) )
self._web_auth = entry_data.get(CONF_CREDENTIAL_TYPE) == CredentialType.WEB_AUTH
return await self.async_step_reauth_confirm() return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm( async def async_step_reauth_confirm(

View File

@ -1,12 +1,12 @@
"""Constants for google integration.""" """Constants for google integration."""
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum, StrEnum
DOMAIN = "google" DOMAIN = "google"
DEVICE_AUTH_IMPL = "device_auth"
CONF_CALENDAR_ACCESS = "calendar_access" CONF_CALENDAR_ACCESS = "calendar_access"
CONF_CREDENTIAL_TYPE = "credential_type"
DATA_CALENDARS = "calendars" DATA_CALENDARS = "calendars"
DATA_SERVICE = "service" DATA_SERVICE = "service"
DATA_CONFIG = "config" DATA_CONFIG = "config"
@ -32,6 +32,13 @@ class FeatureAccess(Enum):
DEFAULT_FEATURE_ACCESS = FeatureAccess.read_write 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_DESCRIPTION = "description"
EVENT_END_DATE = "end_date" EVENT_END_DATE = "end_date"
EVENT_END_DATETIME = "end_date_time" EVENT_END_DATETIME = "end_date_time"

View File

@ -218,7 +218,7 @@ def config_entry(
domain=DOMAIN, domain=DOMAIN,
unique_id=config_entry_unique_id, unique_id=config_entry_unique_id,
data={ data={
"auth_implementation": "device_auth", "auth_implementation": DOMAIN,
"token": { "token": {
"access_token": "ACCESS_TOKEN", "access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN", "refresh_token": "REFRESH_TOKEN",
@ -350,7 +350,9 @@ def component_setup(
async def _setup_func() -> bool: async def _setup_func() -> bool:
assert await async_setup_component(hass, "application_credentials", {}) assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential( 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) config_entry.add_to_hass(hass)
return await hass.config_entries.async_setup(config_entry.entry_id) 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 from collections.abc import Callable
import datetime import datetime
from http import HTTPStatus from http import HTTPStatus
from typing import Any
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from aiohttp.client_exceptions import ClientError from aiohttp.client_exceptions import ClientError
@ -21,9 +22,14 @@ from homeassistant.components.application_credentials import (
ClientCredential, ClientCredential,
async_import_client_credential, 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.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow 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 .conftest import CLIENT_ID, CLIENT_SECRET, EMAIL_ADDRESS, YieldFixture
from tests.common import MockConfigEntry, async_fire_time_changed 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_INTERVAL = 1
CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2) 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) @pytest.fixture(autouse=True)
@ -175,6 +185,7 @@ async def test_full_flow_application_creds(
"scope": "https://www.googleapis.com/auth/calendar", "scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer", "token_type": "Bearer",
}, },
"credential_type": "device_auth",
} }
assert result.get("options") == {"calendar_access": "read_write"} assert result.get("options") == {"calendar_access": "read_write"}
@ -230,7 +241,9 @@ async def test_expired_after_exchange(
) -> None: ) -> None:
"""Test credential exchange expires.""" """Test credential exchange expires."""
await async_import_client_credential( 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( result = await hass.config_entries.flow.async_init(
@ -262,7 +275,9 @@ async def test_exchange_error(
) -> None: ) -> None:
"""Test an error while exchanging the code for credentials.""" """Test an error while exchanging the code for credentials."""
await async_import_client_credential( 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( 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_at")
data["token"].pop("expires_in") data["token"].pop("expires_in")
assert data == { assert data == {
"auth_implementation": "device_auth", "auth_implementation": DOMAIN,
"token": { "token": {
"access_token": "ACCESS_TOKEN", "access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN", "refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar", "scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer", "token_type": "Bearer",
}, },
"credential_type": "device_auth",
} }
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
@ -329,7 +345,7 @@ async def test_duplicate_config_entries(
) -> None: ) -> None:
"""Test that the same account cannot be setup twice.""" """Test that the same account cannot be setup twice."""
await async_import_client_credential( 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 # Load a config entry
@ -371,7 +387,7 @@ async def test_multiple_config_entries(
) -> None: ) -> None:
"""Test that multiple config entries can be set at once.""" """Test that multiple config entries can be set at once."""
await async_import_client_credential( 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 # Load a config entry
@ -455,17 +471,19 @@ async def test_reauth_flow(
mock_code_flow: Mock, mock_code_flow: Mock,
mock_exchange: Mock, mock_exchange: Mock,
) -> None: ) -> None:
"""Test can't configure when config entry already exists.""" """Test reauth of an existing config entry."""
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={ data={
"auth_implementation": "device_auth", "auth_implementation": DOMAIN,
"token": {"access_token": "OLD_ACCESS_TOKEN"}, "token": {"access_token": "OLD_ACCESS_TOKEN"},
}, },
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await async_import_client_credential( 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) 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_at")
data["token"].pop("expires_in") data["token"].pop("expires_in")
assert data == { assert data == {
"auth_implementation": "device_auth", "auth_implementation": DOMAIN,
"token": { "token": {
"access_token": "ACCESS_TOKEN", "access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN", "refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar", "scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer", "token_type": "Bearer",
}, },
"credential_type": "device_auth",
} }
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
@ -540,7 +559,9 @@ async def test_calendar_lookup_failure(
) -> None: ) -> None:
"""Test successful config flow and title fetch fails gracefully.""" """Test successful config flow and title fetch fails gracefully."""
await async_import_client_credential( 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( 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 result["type"] == "create_entry"
assert config_entry.options == {"calendar_access": "read_write"} 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