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:
parent
eaaca3e556
commit
3f70437888
6 changed files with 307 additions and 25 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue